mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
Compare commits
60 Commits
60a52b9873
...
v2.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09942e3d8 | ||
|
|
96febd5904 | ||
|
|
c3cfbc878c | ||
|
|
55bbfabe9f | ||
|
|
f5cdae4853 | ||
|
|
5c4995961c | ||
|
|
1b4dfaaba7 | ||
|
|
549a5bdba1 | ||
|
|
acaf9d5c27 | ||
|
|
e4c6a91350 | ||
|
|
ba369e8a83 | ||
|
|
d01bc8a669 | ||
|
|
e024e0d54e | ||
|
|
c44089169f | ||
|
|
d04e30e3c9 | ||
|
|
229b6c5bc0 | ||
|
|
c3b133d8d9 | ||
|
|
e9a2ab1782 | ||
|
|
c7d6e23398 | ||
|
|
0514efb1c4 | ||
|
|
e32fb85006 | ||
|
|
96df6a1798 | ||
|
|
1f8a97bd16 | ||
|
|
0ab3298cef | ||
|
|
cad41680eb | ||
|
|
3b00f328c5 | ||
|
|
20d0c3e0a7 | ||
|
|
71b5a004e3 | ||
|
|
65b1506d75 | ||
|
|
2a458d5a3c | ||
|
|
f418987f47 | ||
|
|
59b4dea071 | ||
|
|
6ce132fe0c | ||
|
|
49731238e4 | ||
|
|
c5df97f7aa | ||
|
|
3476fda4db | ||
|
|
519b673ae5 | ||
|
|
303b8bedd4 | ||
|
|
f2b9b16896 | ||
|
|
6eba35542b | ||
|
|
f5e95ffba6 | ||
|
|
a75bb86eec | ||
|
|
a3b858621f | ||
|
|
fee2a2c9f5 | ||
|
|
839d7d0866 | ||
|
|
7cd7d95245 | ||
|
|
9bd762f7a3 | ||
|
|
9b76db4ce9 | ||
|
|
91457b6490 | ||
|
|
a0a71623d7 | ||
|
|
00e624b8bc | ||
|
|
f82043127a | ||
|
|
32da4f906e | ||
|
|
0bd9d0ac47 | ||
|
|
9f45fd1ecd | ||
|
|
f528e75ee1 | ||
|
|
da32b41bbc | ||
|
|
1e0c93fb6c | ||
|
|
5f70421f5a | ||
|
|
50477fb1be |
@@ -22,6 +22,13 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# === S3 Server (Opcional) ===
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
|
||||||
# === Email (Opcional) ===
|
# === Email (Opcional) ===
|
||||||
# Provider: Resend (https://resend.com)
|
# Provider: Resend (https://resend.com)
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
|||||||
58
.github/copilot-instructions.md
vendored
58
.github/copilot-instructions.md
vendored
@@ -1,58 +0,0 @@
|
|||||||
# AI Coding Assistant Instructions for OpenMonetis
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
OpenMonetis is a self-hosted personal finance management application built with Next.js 16, TypeScript, PostgreSQL, and Drizzle ORM. It provides manual transaction tracking, account management, budgeting, and financial insights with a Portuguese interface.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Frontend**: Next.js App Router with React 19, shadcn/ui components, Tailwind CSS
|
|
||||||
- **Backend**: Server actions in Next.js, API routes for auth/health
|
|
||||||
- **Database**: PostgreSQL with Drizzle ORM, schema in `db/schema.ts`
|
|
||||||
- **Auth**: Better Auth (OAuth + email magic links)
|
|
||||||
- **Deployment**: Docker multi-stage build, health checks
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
- **Server Actions**: Use `"use server"` for mutations, validate with Zod schemas, handle errors with `handleActionError`
|
|
||||||
- **Database Queries**: Use Drizzle's query API with relations, e.g., `db.query.lancamentos.findMany({ with: { categoria: true } })`
|
|
||||||
- **Authentication**: Import from `lib/auth/server`, redirect on failure
|
|
||||||
- **Revalidation**: Call `revalidateForEntity("lancamentos")` after mutations
|
|
||||||
- **Portuguese Naming**: DB fields like `nome`, `tipo_conta`, `pagador` (payer), `lancamento` (transaction)
|
|
||||||
- **Component Structure**: Feature-based folders in `components/`, shared UI in `components/ui/`
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
- **Start Dev**: `pnpm dev` (Turbopack), `docker compose up db -d` for DB
|
|
||||||
- **Database**: `pnpm db:push` to sync schema, `pnpm db:studio` for visual editor
|
|
||||||
- **Build**: `pnpm build`, `pnpm start` for production
|
|
||||||
- **Docker**: `pnpm docker:up` for full stack, `pnpm docker:logs` for monitoring
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
- **Add Transaction**: Create server action in `app/(dashboard)/lancamentos/actions.ts`, validate with Zod, insert via Drizzle
|
|
||||||
- **New Entity**: Add to `db/schema.ts`, define relations, create CRUD actions in `lib/[entity]/actions.ts`
|
|
||||||
- **UI Component**: Use shadcn/ui, place in `components/[feature]/`, export from `components/ui/`
|
|
||||||
- **API Route**: Add to `app/api/`, use `getUserSession()` for auth
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Imports**: Absolute paths with `@/`, group by external/internal
|
|
||||||
- **Error Handling**: Return `{ success: false, error: string }` from actions
|
|
||||||
- **Currency**: Store as decimal strings (e.g., "123.45"), convert to cents for calculations
|
|
||||||
- **Periods**: Format as "YYYY-MM", use `parsePeriodParam()` for URL params
|
|
||||||
- **Notifications**: Send emails via `sendPagadorAutoEmails()` for payer updates
|
|
||||||
|
|
||||||
## External Integrations
|
|
||||||
|
|
||||||
- **Better Auth**: Config in `lib/auth/config.ts`, session handling
|
|
||||||
- **Drizzle**: Migrations in `drizzle/`, studio at `pnpm db:studio`
|
|
||||||
- **AI Features**: Use `@ai-sdk/*` for insights, configured in environment
|
|
||||||
- **Email**: Resend for notifications, configured via `RESEND_API_KEY`
|
|
||||||
|
|
||||||
## File Examples
|
|
||||||
|
|
||||||
- Schema: `db/schema.ts` (relations, indexes)
|
|
||||||
- Actions: `app/(dashboard)/lancamentos/actions.ts` (CRUD with validation)
|
|
||||||
- Components: `components/lancamentos/page/lancamentos-page.tsx` (client component)
|
|
||||||
- Utils: `lib/lancamentos/page-helpers.ts` (data transformation)
|
|
||||||
24
.github/workflows/docker-publish.yml
vendored
24
.github/workflows/docker-publish.yml
vendored
@@ -15,8 +15,32 @@ env:
|
|||||||
DOCKER_IMAGE_NAME: openmonetis
|
DOCKER_IMAGE_NAME: openmonetis
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm run lint
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: quality
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
59
.github/workflows/release.yml
vendored
Normal file
59
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Read version from package.json
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(jq -r '.version' package.json)
|
||||||
|
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if tag already exists
|
||||||
|
id: tag_check
|
||||||
|
run: |
|
||||||
|
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract changelog for this version
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.value }}"
|
||||||
|
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
|
||||||
|
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||||
|
# Remove linhas em branco do início e fim
|
||||||
|
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
|
||||||
|
{
|
||||||
|
echo "notes<<EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create tag and GitHub Release
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
|
name: ${{ steps.version.outputs.tag }}
|
||||||
|
body: ${{ steps.changelog.outputs.notes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -12,7 +12,6 @@
|
|||||||
"**/.next": true,
|
"**/.next": true,
|
||||||
".next": true
|
".next": true
|
||||||
},
|
},
|
||||||
"explorerExclude.backup": {},
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -25,9 +24,7 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"eslint.enable": false,
|
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
"typescript.preferences.organizeImportsCollation": "ordinal",
|
|
||||||
"editor.fontSize": 15,
|
"editor.fontSize": 15,
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
|
|||||||
155
CHANGELOG.md
155
CHANGELOG.md
@@ -5,6 +5,161 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|||||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.1] - 2026-04-03
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
|
||||||
|
|
||||||
|
## [2.3.0] - 2026-04-03
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side
|
||||||
|
- Dashboard: widget "Minhas Contas" ganha preferência persistida para mostrar ou ocultar contas marcadas como não consideradas no saldo total
|
||||||
|
- Dashboard: cards de métricas ganham botão de ajuda com explicação do cálculo exibido no app
|
||||||
|
- Versionamento: menu do usuário na navbar passa a avisar quando existe release mais recente publicada no GitHub
|
||||||
|
- Qualidade: adiciona `knip` ao projeto com o script `pnpm run lint:deadcode` para auditar arquivos, exports e tipos sem uso
|
||||||
|
- Infraestrutura: imagem Docker passa a rodar migrations automaticamente via `docker-entrypoint.sh` antes de iniciar a aplicação
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Anexos: listagem no modal de edição/detalhes, URLs temporárias da galeria e preview deixam de depender de `useEffect` para data fetching direto no componente e passam a usar React Query sobre rotas GET dedicadas
|
||||||
|
- Insights: carregamento de análises salvas passa a usar React Query com cache por período, mantendo estado draft local apenas para análises recém-geradas ou removidas
|
||||||
|
- Parcelamentos: histórico de antecipações no diálogo passa a usar React Query com invalidação automática após cancelamento
|
||||||
|
- Dashboard, insights e relatórios passam a excluir movimentações de contas marcadas como não consideradas no saldo total; balanço e previsto também passam a considerar ajustes de transferências entre contas consideradas e não consideradas
|
||||||
|
- UX: boletos e faturas passam a exibir labels relativas como "vence hoje", "vence amanhã" e "pago ontem", com tooltip para a data completa
|
||||||
|
- Lançamentos: diálogo foi reorganizado em blocos mais claros; a criação passa a aceitar múltiplos anexos e a edição em lote preserva `purchaseDate` e `period` ao propagar alterações por série
|
||||||
|
- Inbox e tabela de lançamentos foram componentizados em partes menores, mantendo paginação e ações em lote mais simples de evoluir
|
||||||
|
- Infraestrutura: workflow de publish ganha etapa obrigatória de qualidade; `docker-compose` passa a suportar perfil local ou banco remoto; build fixa `pnpm@10.33.0`; projeto atualizado para `Next.js 16.2.2`, `Biome 2.4.10` e dependências correlatas
|
||||||
|
- Qualidade: `knip` ganha configuração inicial para reduzir falsos positivos, ignorando `src/shared/components/ui/**`, o worker público de PDF, `setup.mjs` e o falso positivo de `postcss`
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Segurança: criação de antecipações agora valida se `payerId` e `categoryId` informados pertencem ao usuário autenticado antes de persistir referências cruzadas
|
||||||
|
- Segurança: histórico de antecipações endurece os joins de `transactions`, `payers` e `categories` com filtro por `userId`, evitando exposição de nomes relacionados caso exista referência inconsistente no banco
|
||||||
|
- Segurança: domínio público deixa de responder rotas `/api/*`, e o Better Auth passa a aplicar rate limits explícitos para login e cadastro por e-mail
|
||||||
|
- APIs privadas: rotas de anexos, insights salvos, histórico de antecipações e presign de download passam a responder com `Cache-Control: private, no-store`; a rota de antecipações também deixa de devolver mensagens internas de erro ao cliente
|
||||||
|
- Build: rotas web de tokens do Companion passam a ser explicitamente dinâmicas, removendo o warning de prerender no `next build`
|
||||||
|
- Lançamentos: edição em série de compras parceladas volta a persistir `purchaseDate` e `period`, permitindo mover parcelas para a fatura ou competência correta conforme o escopo escolhido
|
||||||
|
- Lançamentos: edições que tentam mover compras de cartão para faturas já pagas agora são bloqueadas com mensagem clara também no fluxo de atualização e propagação em lote
|
||||||
|
- Imagens: logos institucionais, avatares padrão e componentes com `next/image` em modo `fill` passam a usar containers fixos com `sizes`, removendo avisos de proporção e performance
|
||||||
|
- Gráficos: `ChartContainer` passa a definir `initialDimension` no `ResponsiveContainer` do Recharts, evitando avisos `width(-1)` e `height(-1)` durante a medição inicial em widgets e relatórios
|
||||||
|
|
||||||
|
## [2.2.1] - 2026-04-01
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-04-01
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Anexos: nova página de galeria em `/attachments` com miniaturas, visualização inline de imagem e PDF, download direto e acesso a partir do lançamento
|
||||||
|
- Anexos: suporte a visualização de PDF diretamente no app via `pdfjs-dist`
|
||||||
|
- Autenticação: sidebar redesenhado com mockup de faturas e três itens de funcionalidade; páginas de login e cadastro ganham gradiente decorativo e logo visível no mobile
|
||||||
|
- Notificações: alertas de vencimento para boletos e faturas do período seguinte exibidos quando o vencimento está dentro de 5 dias
|
||||||
|
- Documentação: novo arquivo público `public/llms.txt` com resumo do projeto e links curados para documentação, setup e arquitetura
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Performance: queries de cache do dashboard migradas de `unstable_cache` para a diretiva `use cache` com `cacheTag` e `cacheLife`; todas as páginas do dashboard passam a chamar `connection()` para renderização dinâmica; `next.config.ts` adota `cacheComponents: true`
|
||||||
|
- Tipografia: adicionada fonte America Medium (weight 500); pesos tipográficos padronizados para `font-medium` em títulos, valores e rótulos em todos os componentes
|
||||||
|
- Anexos: `AttachmentPreview` foi simplificado para exibir apenas nome da transação, nome do arquivo, navegação entre anexos e ações de download, abrir em nova aba e fechar com ícone `X`
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries
|
||||||
|
|
||||||
|
## [2.1.2] - 2026-03-30
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
|
||||||
|
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
|
||||||
|
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
|
||||||
|
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
|
||||||
|
|
||||||
|
## [2.1.1] - 2026-03-29
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
|
||||||
|
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
|
||||||
|
- Analytics: integração com Umami self-hosted via script tag no layout raiz
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
|
||||||
|
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
|
||||||
|
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
|
||||||
|
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
|
||||||
|
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
|
||||||
|
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-03-28
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Lançamentos: suporte a anexos em transações com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento
|
||||||
|
- Infraestrutura: novo workflow `.github/workflows/release.yml` para criar tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Anexos: upload agora exige token assinado por arquivo, valida propriedade da transação também na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: criação de transações no cartão de crédito agora bloqueia períodos cujas faturas já estão pagas, evitando divergência no relatório de análise de parcelas
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-03-26
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: `/transactions` deixa de depender de `crypto.randomUUID()` no carregamento inicial, corrigindo a falha em ambientes self-hosted sem HTTPS ao abrir a página
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-03-25
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts`
|
||||||
|
- Navbar: novo estado persistido para notificações do sino, permitindo marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Navbar: o snapshot global de notificações deixa de depender do `periodo` da URL atual e passa a usar o período corrente do negócio; itens lidos saem do badge e itens arquivados somem da lista padrão do sino
|
||||||
|
- Navbar: dropdown de notificações agora permite mostrar itens arquivados e reverter ações de leitura e arquivamento diretamente em cada item
|
||||||
|
- Navbar: filtro da lista de notificações no sino foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`, com destaque visual mais forte para a aba ativa
|
||||||
|
- Navbar: componente `notification-bell` foi desmembrado em hook e componentes locais menores, reduzindo acoplamento e facilitando manutenção
|
||||||
|
- Dashboard: detalhamento por categoria agora oculta categorias sem movimentação no período, reduzindo ruído visual no card
|
||||||
|
- UI: arte decorativa do topo da dashboard foi restrita à faixa do cabeçalho de boas-vindas, evitando que o `dot pattern` e o gradiente claro alterem a leitura visual do month picker
|
||||||
|
- Lançamentos em série: a edição em lote agora também permite propagar o status de pagamento (`isSettled`) para transações não feitas no cartão de crédito
|
||||||
|
- Seed de conta vazia: `scripts/mock-data.ts` agora processa `--help` antes de exigir `DATABASE_URL` e só cria categorias/pagador admin depois de validar que a conta está financeiramente vazia
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Navbar: ao desarquivar a última notificação no modo de arquivadas, o dropdown volta automaticamente para a listagem padrão e o toggle deixa de ficar travado
|
||||||
|
- Filtros financeiros: transações de conta com observação nula, como compras parceladas no Pix, deixam de ser ocultadas indevidamente em `/transactions`, dashboard e relatórios quando a conta está configurada para desconsiderar o saldo inicial
|
||||||
|
- Backup: geração do arquivo `*.data.sql.gz` volta a usar a saída correta do `pg_restore`
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- DB: colunas `system_font` e `money_font` da tabela `preferencias_usuario`, que não são mais utilizadas no código
|
||||||
|
|
||||||
## [2.0.1] - 2026-03-21
|
## [2.0.1] - 2026-03-21
|
||||||
|
|
||||||
### Corrigido
|
### Corrigido
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
|
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
|
||||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||||
|
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +44,10 @@ Use esta pergunta:
|
|||||||
|
|
||||||
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
|
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
|
||||||
|
|
||||||
|
**Excecao intencional: `attachments` depende de `transactions`**
|
||||||
|
|
||||||
|
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
|
||||||
|
|
||||||
Exemplos comuns:
|
Exemplos comuns:
|
||||||
|
|
||||||
- auth: `src/shared/lib/auth/*`
|
- auth: `src/shared/lib/auth/*`
|
||||||
|
|||||||
56
Dockerfile
56
Dockerfile
@@ -5,14 +5,16 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
# Instalar pnpm globalmente
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Criar pasta public para o postinstall do pdfjs-dist
|
||||||
|
RUN mkdir -p public
|
||||||
|
|
||||||
# Instalar dependências (production + dev para o build)
|
# Instalar dependências (production + dev para o build)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -21,8 +23,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
# Instalar pnpm globalmente
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -32,13 +33,14 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copiar todo o código fonte
|
# Copiar todo o código fonte
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Garantir que o pdf.worker vem da versão instalada no stage 1, não do host
|
||||||
|
COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
|
||||||
|
|
||||||
# Variáveis de ambiente necessárias para o build
|
# Variáveis de ambiente necessárias para o build
|
||||||
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# Build da aplicação Next.js
|
# Build da aplicação Next.js
|
||||||
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -46,8 +48,7 @@ RUN pnpm build
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
# Instalar pnpm globalmente
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -55,12 +56,27 @@ WORKDIR /app
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copiar apenas arquivos necessários para produção
|
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
|
||||||
COPY --from=builder /app/public ./public
|
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json /tmp/pkg.json
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
RUN mkdir -p /app/migrate && \
|
||||||
|
node -e "\
|
||||||
|
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
|
||||||
|
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
|
||||||
|
name:'openmonetis-migrate',version:p.version,\
|
||||||
|
dependencies:{\
|
||||||
|
'drizzle-orm':p.dependencies['drizzle-orm'],\
|
||||||
|
'pg':p.dependencies['pg']\
|
||||||
|
},\
|
||||||
|
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
|
||||||
|
}));" && \
|
||||||
|
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
|
||||||
|
chown -R nextjs:nodejs /app/migrate
|
||||||
|
|
||||||
# Copiar arquivos de build do Next.js
|
# Copiar apenas arquivos necessários para produção
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
@@ -69,8 +85,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
|
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
||||||
|
|
||||||
# Copiar node_modules para ter drizzle-kit disponível para migrations
|
# Copiar entrypoint de migrations
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
COPY docker-entrypoint.sh ./
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
|
||||||
|
|
||||||
# Definir variáveis de ambiente de produção
|
# Definir variáveis de ambiente de produção
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
@@ -81,16 +98,13 @@ ENV NODE_ENV=production \
|
|||||||
# Expor porta
|
# Expor porta
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Ajustar permissões para o usuário nextjs
|
|
||||||
RUN chown -R nextjs:nodejs /app
|
|
||||||
|
|
||||||
# Mudar para usuário não-root
|
# Mudar para usuário não-root
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
|
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
# Comando de inicialização
|
# Entrypoint: roda migrations e depois executa o CMD
|
||||||
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
- [Início Rápido (manual)](#-início-rápido)
|
- [Início Rápido (manual)](#-início-rápido)
|
||||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||||
- [Docker](#-docker)
|
- [Docker](#-docker)
|
||||||
|
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||||
- [Arquitetura](#-arquitetura)
|
- [Arquitetura](#-arquitetura)
|
||||||
- [Contribuindo](#-contribuindo)
|
- [Contribuindo](#-contribuindo)
|
||||||
@@ -155,7 +156,7 @@ O script irá:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up db -d
|
docker compose up db -d
|
||||||
pnpm db:enableExtensions
|
pnpm db:extensions
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Execute as migrations e inicie**
|
4. **Execute as migrations e inicie**
|
||||||
@@ -238,6 +239,30 @@ DB_PORT=5433 # Padrão: 5432
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ☁️ Storage S3 Compatível
|
||||||
|
|
||||||
|
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
||||||
|
|
||||||
|
### Variáveis
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibilidade
|
||||||
|
|
||||||
|
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||||
|
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
|
||||||
|
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
||||||
|
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
||||||
|
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔐 Variáveis de Ambiente
|
## 🔐 Variáveis de Ambiente
|
||||||
|
|
||||||
Copie `.env.example` para `.env` e configure:
|
Copie `.env.example` para `.env` e configure:
|
||||||
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
|
|||||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||||
POSTGRES_DB=openmonetis_db
|
POSTGRES_DB=openmonetis_db
|
||||||
|
|
||||||
|
# S3 Server (opcional, necessario para anexos)
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
|
||||||
# Multi-domínio (landing-only no domínio público)
|
# Multi-domínio (landing-only no domínio público)
|
||||||
# PUBLIC_DOMAIN=openmonetis.com
|
# PUBLIC_DOMAIN=openmonetis.com
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -4,23 +4,28 @@ name: openmonetis
|
|||||||
# MODOS DE USO:
|
# MODOS DE USO:
|
||||||
# 1. Banco LOCAL (PostgreSQL em container):
|
# 1. Banco LOCAL (PostgreSQL em container):
|
||||||
# - Configure DATABASE_URL com host "db" no .env
|
# - Configure DATABASE_URL com host "db" no .env
|
||||||
# - Execute: docker compose up --build
|
# - Execute: docker compose --profile local up
|
||||||
#
|
#
|
||||||
# 2. Banco REMOTO (ex: Supabase):
|
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
||||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||||
# - Execute: docker compose up app --build (apenas o serviço app)
|
# - Execute: docker compose up
|
||||||
#
|
#
|
||||||
# 3. Para parar todos os serviços:
|
# 3. Build local (desenvolvimento):
|
||||||
|
# - Execute: docker compose --profile local up --build
|
||||||
|
#
|
||||||
|
# 4. Para parar todos os serviços:
|
||||||
# - Execute: docker compose down
|
# - Execute: docker compose down
|
||||||
#
|
#
|
||||||
# 4. Para remover volumes (CUIDADO: apaga dados do banco local):
|
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
|
||||||
# - Execute: docker compose down -v
|
# - Execute: docker compose down -v
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
# ============================================
|
||||||
# Serviço: PostgreSQL (Banco de dados local)
|
# Serviço: PostgreSQL (Banco de dados local)
|
||||||
|
# Ativado apenas com: --profile local
|
||||||
# ============================================
|
# ============================================
|
||||||
db:
|
db:
|
||||||
|
profiles: ["local"]
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: openmonetis_postgres
|
container_name: openmonetis_postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -29,22 +34,21 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||||
# Garante que os dados ficam no volume montado (evita perda após down/up)
|
|
||||||
PGDATA: /var/lib/postgresql/data
|
PGDATA: /var/lib/postgresql/data
|
||||||
# Configurações de performance
|
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# Mapeia porta 5432 do container para 5432 do host
|
|
||||||
# Útil para conectar com ferramentas externas (ex: DBeaver, pgAdmin)
|
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Volume nomeado para persistência de dados
|
|
||||||
# Os dados sobrevivem ao restart do container
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
# Script de inicialização (cria extensão pgcrypto automaticamente)
|
|
||||||
- ./scripts/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
|
||||||
|
exec docker-entrypoint.sh postgres
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@@ -57,80 +61,60 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
networks:
|
# Para ativar logs de queries (debug), adicione ao command acima:
|
||||||
- openmonetis_network
|
# exec docker-entrypoint.sh postgres -c log_statement=all
|
||||||
|
|
||||||
# Descomentar para ativar logs de queries (debug)
|
|
||||||
# command: ["postgres", "-c", "log_statement=all"]
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Serviço: Aplicação Next.js
|
# Serviço: Aplicação Next.js
|
||||||
# ============================================
|
# ============================================
|
||||||
app:
|
app:
|
||||||
build:
|
build: .
|
||||||
context: .
|
image: felipegcoutinho/openmonetis:latest
|
||||||
dockerfile: Dockerfile
|
|
||||||
|
|
||||||
container_name: openmonetis_app
|
container_name: openmonetis_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# Mapeia porta 3000 do container para 3000 do host
|
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Variáveis de ambiente da aplicação
|
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|
||||||
# DATABASE_URL do .env
|
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
||||||
# Banco local: use host "db" (serviço Docker)
|
|
||||||
# Banco remoto: use a URL completa do provider
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
|
||||||
# Outras variáveis de ambiente necessárias
|
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
|
||||||
# Configurações de email (se usar)
|
# S3 (opcional)
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-}
|
||||||
|
|
||||||
|
# Email (opcional)
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||||
|
|
||||||
# Configurações de OAuth (se usar)
|
# OAuth (opcional)
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||||
|
|
||||||
# Configurações de AI providers (se usar)
|
# AI providers (opcional)
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
|
|
||||||
# Só depende do 'db' se estiver usando banco local
|
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
||||||
# Para banco remoto, comente a linha abaixo ou suba apenas: docker compose up app
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
required: false
|
||||||
|
|
||||||
networks:
|
|
||||||
- openmonetis_network
|
|
||||||
|
|
||||||
# Script de inicialização: roda migrations antes de iniciar o app
|
|
||||||
# ATENÇÃO: Em produção, considere rodar migrations separadamente por segurança
|
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
|
||||||
command:
|
|
||||||
- |
|
|
||||||
echo "🚀 Aguardando banco de dados..."
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
echo "📦 Rodando migrations..."
|
|
||||||
pnpm db:push || echo "⚠️ Migrations falharam ou já estão atualizadas"
|
|
||||||
|
|
||||||
echo "✅ Iniciando aplicação Next.js..."
|
|
||||||
node server.js
|
|
||||||
|
|
||||||
# Healthcheck da aplicação
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -151,13 +135,4 @@ services:
|
|||||||
# ============================================
|
# ============================================
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
name: openmonetis_postgres_data
|
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Networks
|
|
||||||
# ============================================
|
|
||||||
networks:
|
|
||||||
openmonetis_network:
|
|
||||||
name: openmonetis_network
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
15
docker-entrypoint.sh
Normal file
15
docker-entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rodando migrations..."
|
||||||
|
RETRIES=5
|
||||||
|
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
||||||
|
RETRIES=$((RETRIES - 1))
|
||||||
|
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$RETRIES" -eq 0 ]; then
|
||||||
|
echo "Aviso: migrations nao foram aplicadas"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
1
drizzle/0010_lame_psynapse.sql
Normal file
1
drizzle/0010_lame_psynapse.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-- placeholder: migration aplicada via db:push, arquivo original não preservado
|
||||||
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "anexos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"chave_arquivo" text NOT NULL,
|
||||||
|
"nome_arquivo" text NOT NULL,
|
||||||
|
"tamanho_bytes" integer NOT NULL,
|
||||||
|
"mime_type" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "anexos_chave_arquivo_unique" UNIQUE("chave_arquivo")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dashboard_notification_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"notification_key" text NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lancamento_anexos" (
|
||||||
|
"lancamento_id" uuid NOT NULL,
|
||||||
|
"anexo_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "lancamento_anexos_lancamento_id_anexo_id_pk" PRIMARY KEY("lancamento_id","anexo_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "anexos" ADD CONSTRAINT "anexos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_anexo_id_anexos_id_fk" FOREIGN KEY ("anexo_id") REFERENCES "public"."anexos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamento_anexos_anexo_id_idx" ON "lancamento_anexos" USING btree ("anexo_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||||
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;
|
||||||
2
drizzle/0026_next_blue_blade.sql
Normal file
2
drizzle/0026_next_blue_blade.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||||
14
drizzle/0027_glorious_mindworm.sql
Normal file
14
drizzle/0027_glorious_mindworm.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "dashboard_notification_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"notification_key" text NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");
|
||||||
2704
drizzle/meta/0023_snapshot.json
Normal file
2704
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2711
drizzle/meta/0024_snapshot.json
Normal file
2711
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,20 @@
|
|||||||
"when": 1748000000000,
|
"when": 1748000000000,
|
||||||
"tag": "0022_import-category-mappings",
|
"tag": "0022_import-category-mappings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774529878374,
|
||||||
|
"tag": "0023_sturdy_wolfpack",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774891206703,
|
||||||
|
"tag": "0024_petite_lucky_pierre",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
22
knip.jsonc
Normal file
22
knip.jsonc
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
|
||||||
|
// Exclude shared UI primitives from dead code reporting while we focus the
|
||||||
|
// cleanup on feature and domain code first.
|
||||||
|
"ignore": [
|
||||||
|
"src/shared/components/ui/**"
|
||||||
|
],
|
||||||
|
// Runtime asset referenced by string in the PDF viewer.
|
||||||
|
"ignoreFiles": [
|
||||||
|
"public/pdf.worker.min.mjs",
|
||||||
|
"setup.mjs"
|
||||||
|
],
|
||||||
|
// PostCSS is inferred from the config file, but the project only depends on
|
||||||
|
// the Tailwind PostCSS plugin directly.
|
||||||
|
"ignoreDependencies": [
|
||||||
|
"postcss"
|
||||||
|
],
|
||||||
|
"next": true,
|
||||||
|
"postcss": true,
|
||||||
|
"biome": true,
|
||||||
|
"drizzle": true
|
||||||
|
}
|
||||||
@@ -6,16 +6,20 @@ dotenv.config();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
experimental: {
|
cacheComponents: true,
|
||||||
turbopackFileSystemCacheForDev: true,
|
|
||||||
},
|
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
||||||
},
|
},
|
||||||
devIndicators: {
|
devIndicators: {
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
prefetchInlining: true,
|
||||||
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Headers for Safari compatibility
|
// Headers for Safari compatibility
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
206
package.json
206
package.json
@@ -1,102 +1,108 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.0.1",
|
"version": "2.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"packageManager": "pnpm@10.33.0",
|
||||||
"dev": "next dev --turbopack",
|
"scripts": {
|
||||||
"dev-env": "tsx scripts/dev.ts",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"db:seed": "tsx scripts/mock-data.ts",
|
||||||
"start": "next start",
|
"build": "next build",
|
||||||
"lint": "biome check .",
|
"start": "next start",
|
||||||
"lint:fix": "biome check --write .",
|
"lint": "biome check .",
|
||||||
"env:setup": "bash scripts/setup-env.sh",
|
"lint:deadcode": "knip --reporter compact",
|
||||||
"db:generate": "drizzle-kit generate",
|
"lint:fix": "biome check --write .",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"env:setup": "bash scripts/setup-env.sh",
|
||||||
"db:push": "drizzle-kit push",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:enableExtensions": "tsx scripts/postgres/enable-extensions.ts",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:push": "drizzle-kit push",
|
||||||
"docker:up": "docker compose up --build",
|
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||||
"docker:up:db": "docker compose up -d db",
|
"db:studio": "drizzle-kit studio",
|
||||||
"docker:up:d": "docker compose up --build -d",
|
"docker:up": "docker compose up --build",
|
||||||
"docker:down": "docker compose down",
|
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
"docker:down:volumes": "docker compose down -v",
|
"docker:up:db": "docker compose up -d db",
|
||||||
"docker:logs": "docker compose logs -f",
|
"docker:up:d": "docker compose up --build -d",
|
||||||
"docker:logs:app": "docker compose logs -f app",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs:db": "docker compose logs -f db",
|
"docker:down:volumes": "docker compose down -v",
|
||||||
"docker:restart": "docker compose restart",
|
"docker:logs": "docker compose logs -f",
|
||||||
"docker:rebuild": "docker compose up --build --force-recreate",
|
"docker:logs:app": "docker compose logs -f app",
|
||||||
"backup": "bash scripts/backup.sh"
|
"docker:logs:db": "docker compose logs -f db",
|
||||||
},
|
"docker:restart": "docker compose restart",
|
||||||
"dependencies": {
|
"docker:rebuild": "docker compose up --build --force-recreate",
|
||||||
"@ai-sdk/anthropic": "^3.0.63",
|
"backup": "bash scripts/backup.sh"
|
||||||
"@ai-sdk/google": "^3.0.52",
|
},
|
||||||
"@ai-sdk/openai": "^3.0.47",
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.5.5",
|
"@ai-sdk/anthropic": "^3.0.65",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@ai-sdk/google": "^3.0.55",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@ai-sdk/openai": "^3.0.49",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@aws-sdk/client-s3": "^3.1022.0",
|
||||||
"@openrouter/ai-sdk-provider": "^2.3.3",
|
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
"@better-auth/passkey": "^1.5.6",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@radix-ui/react-checkbox": "1.3.3",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@radix-ui/react-collapsible": "1.1.12",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "1.1.15",
|
"@openrouter/ai-sdk-provider": "^2.3.3",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
"@radix-ui/react-label": "2.1.8",
|
"@radix-ui/react-checkbox": "1.3.3",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-collapsible": "1.1.12",
|
||||||
"@radix-ui/react-progress": "1.1.8",
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||||
"@radix-ui/react-select": "2.2.6",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-separator": "1.1.8",
|
"@radix-ui/react-label": "2.1.8",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-switch": "1.2.6",
|
"@radix-ui/react-progress": "1.1.8",
|
||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-toggle": "1.1.10",
|
"@radix-ui/react-select": "2.2.6",
|
||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-separator": "1.1.8",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@remixicon/react": "4.9.0",
|
"@radix-ui/react-switch": "1.2.6",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"ai": "^6.0.134",
|
"@remixicon/react": "4.9.0",
|
||||||
"better-auth": "1.5.5",
|
"@tanstack/react-query": "^5.96.2",
|
||||||
"canvas-confetti": "^1.9.4",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"clsx": "2.1.1",
|
"ai": "^6.0.143",
|
||||||
"cmdk": "^1.1.1",
|
"better-auth": "1.5.6",
|
||||||
"date-fns": "^4.1.0",
|
"canvas-confetti": "^1.9.4",
|
||||||
"drizzle-orm": "0.45.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"jspdf": "^4.2.1",
|
"clsx": "2.1.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"cmdk": "^1.1.1",
|
||||||
"next": "16.1.7",
|
"date-fns": "^4.1.0",
|
||||||
"next-themes": "0.4.6",
|
"drizzle-orm": "0.45.2",
|
||||||
"pg": "8.20.0",
|
"jspdf": "^4.2.1",
|
||||||
"radix-ui": "^1.4.3",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"react": "19.2.4",
|
"next": "16.2.2",
|
||||||
"react-day-picker": "^9.14.0",
|
"next-themes": "0.4.6",
|
||||||
"react-dom": "19.2.4",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"recharts": "3.8.0",
|
"pg": "8.20.0",
|
||||||
"resend": "^6.9.4",
|
"radix-ui": "^1.4.3",
|
||||||
"sonner": "2.0.7",
|
"react": "19.2.4",
|
||||||
"tailwind-merge": "3.5.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"vaul": "1.1.2",
|
"react-dom": "19.2.4",
|
||||||
"xlsx": "^0.18.5",
|
"recharts": "3.8.1",
|
||||||
"zod": "4.3.6"
|
"resend": "^6.10.0",
|
||||||
},
|
"sonner": "2.0.7",
|
||||||
"devDependencies": {
|
"tailwind-merge": "3.5.0",
|
||||||
"@biomejs/biome": "2.4.8",
|
"vaul": "1.1.2",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"xlsx": "^0.18.5",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"zod": "4.3.6"
|
||||||
"@types/node": "25.5.0",
|
},
|
||||||
"@types/pg": "^8.20.0",
|
"devDependencies": {
|
||||||
"@types/react": "19.2.14",
|
"@biomejs/biome": "2.4.10",
|
||||||
"@types/react-dom": "19.2.3",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"dotenv": "^17.3.1",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"@types/node": "25.5.0",
|
||||||
"tailwindcss": "4.2.2",
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "4.21.0",
|
"@types/react": "19.2.14",
|
||||||
"typescript": "5.9.3"
|
"@types/react-dom": "19.2.3",
|
||||||
}
|
"dotenv": "^17.4.0",
|
||||||
|
"drizzle-kit": "0.31.10",
|
||||||
|
"knip": "^6.3.0",
|
||||||
|
"tailwindcss": "4.2.2",
|
||||||
|
"tsx": "4.21.0",
|
||||||
|
"typescript": "6.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2617
pnpm-lock.yaml
generated
2617
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/fonts/america-medium.woff2
Normal file
BIN
public/fonts/america-medium.woff2
Normal file
Binary file not shown.
@@ -7,9 +7,12 @@ export const america = localFont({
|
|||||||
weight: "400",
|
weight: "400",
|
||||||
style: "normal",
|
style: "normal",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "./america-medium.woff2",
|
||||||
|
weight: "500",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
display: "swap",
|
display: "fallback",
|
||||||
variable: "--font-america",
|
variable: "--font-america",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const americaFontVariable = america.variable;
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
37
public/llms.txt
Normal file
37
public/llms.txt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# OpenMonetis
|
||||||
|
|
||||||
|
> OpenMonetis is a self-hosted personal finance web app for manual financial control. It helps users manage accounts, cards, invoices, budgets, notes, reports, attachments, and AI-generated insights. The product UI is in Brazilian Portuguese, the codebase uses English folder and import names, and there is no hosted SaaS version.
|
||||||
|
>
|
||||||
|
> **Stack:** Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind CSS 4, shadcn/ui. Package manager: pnpm. Linter: Biome.
|
||||||
|
|
||||||
|
OpenMonetis is meant to be deployed by the user on their own machine or server.
|
||||||
|
There is no Open Finance or automatic bank synchronization.
|
||||||
|
Transactions can be entered manually or imported from OFX and XLS/XLSX files.
|
||||||
|
Attachments are optional and require S3-compatible storage.
|
||||||
|
The public website is mainly a landing page; the main technical documentation lives in the GitHub repository.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [Landing page](/): Public homepage and high-level product overview
|
||||||
|
- [README](https://github.com/felipegcoutinho/openmonetis/blob/main/README.md): Main project documentation covering features, installation, Docker, environment variables, architecture, contributing, and license
|
||||||
|
- [CHANGELOG](https://github.com/felipegcoutinho/openmonetis/blob/main/CHANGELOG.md): Release history and notable changes
|
||||||
|
- [LICENSE](https://github.com/felipegcoutinho/openmonetis/blob/main/LICENSE): CC BY-NC-SA 4.0 license terms
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- [Setup script](https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs): Interactive installer for local or self-hosted setup
|
||||||
|
- [Environment example](https://github.com/felipegcoutinho/openmonetis/blob/main/.env.example): Required and optional environment variables
|
||||||
|
- [Docker Compose](https://github.com/felipegcoutinho/openmonetis/blob/main/docker-compose.yml): Local app and PostgreSQL stack definition
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- [CLAUDE.md](https://github.com/felipegcoutinho/openmonetis/blob/main/CLAUDE.md): Project architecture, naming rules, query rules, and feature checklist
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [robots.txt](/robots.txt): Crawl policy for the public site
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [OpenMonetis Companion](https://github.com/felipegcoutinho/openmonetis-companion): Android app that captures bank notifications and sends them to the OpenMonetis inbox for review
|
||||||
|
|
||||||
28
public/pdf.worker.min.mjs
Normal file
28
public/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
@@ -81,7 +81,7 @@ fi
|
|||||||
|
|
||||||
# Extrai dados puros do dump custom (sem nova conexão ao banco)
|
# Extrai dados puros do dump custom (sem nova conexão ao banco)
|
||||||
pg_restore --data-only --schema=public --no-owner --no-privileges \
|
pg_restore --data-only --schema=public --no-owner --no-privileges \
|
||||||
"$DUMP_FILE" | gzip > "$DATA_FILE"
|
-f - "$DUMP_FILE" | gzip > "$DATA_FILE"
|
||||||
|
|
||||||
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"
|
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"
|
||||||
|
|
||||||
|
|||||||
@@ -135,11 +135,11 @@ type SeedSummary = {
|
|||||||
function printUsage() {
|
function printUsage() {
|
||||||
console.log(`
|
console.log(`
|
||||||
Uso:
|
Uso:
|
||||||
pnpm seed:empty-account -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
|
pnpm mockup -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
|
||||||
|
|
||||||
Exemplos:
|
Exemplos:
|
||||||
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2026-01
|
pnpm mockup -- --userId=user_123 --startPeriod=2026-01
|
||||||
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2025-10 --months=8
|
pnpm mockup -- --userId=user_123 --startPeriod=2025-10 --months=8
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,11 +766,12 @@ async function seedInvoicesForCards(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
throw new Error("DATABASE_URL não está configurada no ambiente.");
|
throw new Error("DATABASE_URL não está configurada no ambiente.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = parseArgs(process.argv.slice(2));
|
|
||||||
const logoOptions = await loadLogoOptions();
|
const logoOptions = await loadLogoOptions();
|
||||||
const avatarOptions = await loadAvatarOptions();
|
const avatarOptions = await loadAvatarOptions();
|
||||||
const businessToday = getBusinessTodayInfo();
|
const businessToday = getBusinessTodayInfo();
|
||||||
@@ -794,9 +795,8 @@ async function main() {
|
|||||||
throw new Error(`Usuário ${options.userId} não foi encontrado.`);
|
throw new Error(`Usuário ${options.userId} não foi encontrado.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureCategories(targetUser.id);
|
|
||||||
const adminPayer = await ensureAdminPayer(targetUser);
|
|
||||||
await assertFinancialSpaceIsEmpty(targetUser.id);
|
await assertFinancialSpaceIsEmpty(targetUser.id);
|
||||||
|
const adminPayer = await ensureAdminPayer(targetUser);
|
||||||
|
|
||||||
const categoriesByName = await ensureCategories(targetUser.id);
|
const categoriesByName = await ensureCategories(targetUser.id);
|
||||||
|
|
||||||
|
|||||||
39
setup.mjs
39
setup.mjs
@@ -21,6 +21,7 @@ const c = {
|
|||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
|
orange: "\x1b[38;5;214m",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sym = {
|
const sym = {
|
||||||
@@ -81,10 +82,38 @@ function abort(msg) {
|
|||||||
|
|
||||||
// ─── Header ──────────────────────────────────────────────────────────────────
|
// ─── Header ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
console.log(`
|
const logoLines = [
|
||||||
${c.bold}${c.cyan} OpenMonetis — Setup${c.reset}
|
".............................+@@@@@@@@@@=.............................",
|
||||||
${c.dim}Gestão financeira self-hosted${c.reset}
|
".............................@@@@@@@@@@@:.............................",
|
||||||
`);
|
"...................+@@@@@@*-:@@@@@@@@@@%...=@@@@@@-...................",
|
||||||
|
"..................@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%..................",
|
||||||
|
"................=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+................",
|
||||||
|
"...................-=+%@@@@@@@@@@@@@@@@@@@@@*:........................",
|
||||||
|
".......................#@@@@@@@@@@@@@@@@@@@@@@@+......................",
|
||||||
|
"....................%@@@@@@@@@@@@@%#@@@@@@@@@@@@*.....................",
|
||||||
|
"....................+@@@@@@@@@@@......*@@@@@@#........................",
|
||||||
|
".........................:#@@=...........+#...........................",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameLines = [
|
||||||
|
" ___ __ __ _ _ ",
|
||||||
|
" / _ \\ _ __ ___ _ __ | \\/ | ___ _ __ ___| |_(_)___ ",
|
||||||
|
" | | | | '_ \\ / _ \\ '_ \\| |\\/| |/ _ \\| '_ \\ / _ \\ __| / __|",
|
||||||
|
" | |_| | |_) | __/ | | | | | | (_) | | | | __/ |_| \\__ \\",
|
||||||
|
" \\___/| .__/ \\___|_| |_|_| |_|\\___/|_| |_|\\___|\\__|_|___/",
|
||||||
|
" |_| ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameStart = Math.floor((logoLines.length - nameLines.length) / 2);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
for (let i = 0; i < logoLines.length; i++) {
|
||||||
|
const logoCol = c.orange + logoLines[i].replaceAll(".", " ").substring(14, 56).padEnd(42) + c.reset;
|
||||||
|
const nameIdx = i - nameStart;
|
||||||
|
const nameCol = nameIdx >= 0 && nameIdx < nameLines.length ? nameLines[nameIdx] : "";
|
||||||
|
console.log(logoCol + " " + nameCol);
|
||||||
|
}
|
||||||
|
console.log(`\n${" ".repeat(46)}${c.dim}Gestão financeira · self-hosted${c.reset}\n`);
|
||||||
|
|
||||||
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -329,7 +358,7 @@ if (useLocalDocker) {
|
|||||||
// Extensões
|
// Extensões
|
||||||
s = spinner("Habilitando extensões do banco...");
|
s = spinner("Habilitando extensões do banco...");
|
||||||
try {
|
try {
|
||||||
run("pnpm db:enableExtensions", { cwd: targetDir });
|
run("pnpm db:extensions", { cwd: targetDir });
|
||||||
s.stop("Extensões habilitadas");
|
s.stop("Extensões habilitadas");
|
||||||
} catch {
|
} catch {
|
||||||
s.fail("Falha ao habilitar extensões");
|
s.fail("Falha ao habilitar extensões");
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||||
<div className="w-full max-w-sm md:max-w-5xl">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6 flex md:hidden">
|
||||||
|
<Logo variant="compact" colorIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm md:max-w-5xl">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
export default function Page() {
|
export default function SignupPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||||
<div className="w-full max-w-sm md:max-w-5xl">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6 flex md:hidden">
|
||||||
|
<Logo variant="compact" colorIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm md:max-w-5xl">
|
||||||
<SignupForm />
|
<SignupForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||||
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
||||||
import type { Account } from "@/features/accounts/components/types";
|
import type { Account } from "@/features/accounts/components/types";
|
||||||
@@ -42,6 +43,7 @@ const capitalize = (value: string) =>
|
|||||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { accountId } = await params;
|
const { accountId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
@@ -190,6 +192,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { AccountsPage } from "@/features/accounts/components/accounts-page";
|
import { AccountsPage } from "@/features/accounts/components/accounts-page";
|
||||||
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
|
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { activeAccounts, archivedAccounts, logoOptions } =
|
const { activeAccounts, archivedAccounts, logoOptions } =
|
||||||
await fetchAllAccountsForUser(userId);
|
await fetchAllAccountsForUser(userId);
|
||||||
|
|||||||
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function AnexosLoading() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6">
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Month navigation */}
|
||||||
|
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Count */}
|
||||||
|
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col overflow-hidden rounded-lg border"
|
||||||
|
>
|
||||||
|
<Skeleton className="aspect-square w-full bg-foreground/10" />
|
||||||
|
<div className="space-y-1.5 p-2.5">
|
||||||
|
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
|
||||||
|
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
|
||||||
|
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/(dashboard)/attachments/page.tsx
Normal file
36
src/app/(dashboard)/attachments/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
|
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
|
||||||
|
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
|
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams?: PageSearchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSingleParam = (
|
||||||
|
params: Record<string, string | string[] | undefined> | undefined,
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
const value = params?.[key];
|
||||||
|
if (!value) return null;
|
||||||
|
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
|
const userId = await getUserId();
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
const { period } = parsePeriodParam(periodoParam);
|
||||||
|
|
||||||
|
const attachments = await fetchAttachmentsForPeriod(userId, period);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6">
|
||||||
|
<AttachmentsPage attachments={attachments} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { BudgetsPage } from "@/features/budgets/components/budgets-page";
|
import { BudgetsPage } from "@/features/budgets/components/budgets-page";
|
||||||
import { fetchBudgetsForUser } from "@/features/budgets/queries";
|
import { fetchBudgetsForUser } from "@/features/budgets/queries";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
@@ -23,6 +24,7 @@ const capitalize = (value: string) =>
|
|||||||
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
|
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
|
||||||
import { fetchCalendarData } from "@/features/calendar/queries";
|
import { fetchCalendarData } from "@/features/calendar/queries";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedParams = searchParams ? await searchParams : undefined;
|
const resolvedParams = searchParams ? await searchParams : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import type { FinancialAccount } from "@/db/schema";
|
import type { FinancialAccount } from "@/db/schema";
|
||||||
import { CardDialog } from "@/features/cards/components/card-dialog";
|
import { CardDialog } from "@/features/cards/components/card-dialog";
|
||||||
import type { Card } from "@/features/cards/components/types";
|
import type { Card } from "@/features/cards/components/types";
|
||||||
@@ -39,6 +40,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { cardId } = await params;
|
const { cardId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
@@ -202,6 +204,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate
|
allowCreate
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
defaultCardId={card.id}
|
defaultCardId={card.id}
|
||||||
defaultPaymentMethod="Cartão de crédito"
|
defaultPaymentMethod="Cartão de crédito"
|
||||||
lockCardSelection
|
lockCardSelection
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { CardsPage } from "@/features/cards/components/cards-page";
|
import { CardsPage } from "@/features/cards/components/cards-page";
|
||||||
import { fetchAllCardsForUser } from "@/features/cards/queries";
|
import { fetchAllCardsForUser } from "@/features/cards/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { activeCards, archivedCards, accounts, logoOptions } =
|
const { activeCards, archivedCards, accounts, logoOptions } =
|
||||||
await fetchAllCardsForUser(userId);
|
await fetchAllCardsForUser(userId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
|
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
|
||||||
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
|
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
|
||||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||||
@@ -32,6 +33,7 @@ const getSingleParam = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { categoryId } = await params;
|
const { categoryId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
@@ -99,6 +101,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
||||||
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
export default async function HistoricoCategoriasPage() {
|
export default async function HistoricoCategoriasPage() {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const currentPeriod = getCurrentPeriod();
|
const currentPeriod = getCurrentPeriod();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { CategoriesPage } from "@/features/categories/components/categories-page";
|
import { CategoriesPage } from "@/features/categories/components/categories-page";
|
||||||
import { fetchCategoriesForUser } from "@/features/categories/queries";
|
import { fetchCategoriesForUser } from "@/features/categories/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const categories = await fetchCategoriesForUser(userId);
|
const categories = await fetchCategoriesForUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||||
@@ -14,6 +15,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
||||||
import {
|
import {
|
||||||
type ResolvedInboxSearchParams,
|
type ResolvedInboxSearchParams,
|
||||||
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { InsightsPage } from "@/features/insights/components/insights-page";
|
import { InsightsPage } from "@/features/insights/components/insights-page";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
@@ -18,6 +19,7 @@ const getSingleParam = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
||||||
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
||||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||||
import { getUserSession } from "@/shared/lib/auth/server";
|
import { getUserSession } from "@/shared/lib/auth/server";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
searchParams,
|
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}>) {
|
}>) {
|
||||||
|
await connection();
|
||||||
const session = await getUserSession();
|
const session = await getUserSession();
|
||||||
|
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||||
// Buscar notificações para o período atual
|
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
|
||||||
const periodoParam = resolvedSearchParams?.periodo;
|
|
||||||
const singlePeriodoParam =
|
|
||||||
typeof periodoParam === "string"
|
|
||||||
? periodoParam
|
|
||||||
: Array.isArray(periodoParam)
|
|
||||||
? periodoParam[0]
|
|
||||||
: null;
|
|
||||||
const { period: currentPeriod } = parsePeriodParam(
|
|
||||||
singlePeriodoParam ?? null,
|
|
||||||
);
|
|
||||||
const navbarData = await fetchDashboardNavbarData(
|
|
||||||
session.user.id,
|
|
||||||
currentPeriod,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
@@ -40,7 +23,7 @@ export default async function DashboardLayout({
|
|||||||
notificationsSnapshot={navbarData.notificationsSnapshot}
|
notificationsSnapshot={navbarData.notificationsSnapshot}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-1 flex-col pt-16">
|
<div className="relative flex flex-1 flex-col pt-16">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
|
||||||
<DotPattern
|
<DotPattern
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { NotesPage } from "@/features/notes/components/notes-page";
|
import { NotesPage } from "@/features/notes/components/notes-page";
|
||||||
import { fetchAllNotesForUser } from "@/features/notes/queries";
|
import { fetchAllNotesForUser } from "@/features/notes/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
|
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
|
||||||
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
|
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
|
||||||
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
|
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
|
||||||
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
|
|||||||
type OptionSet = ReturnType<typeof buildOptionSets>;
|
type OptionSet = ReturnType<typeof buildOptionSets>;
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { payerId } = await params;
|
const { payerId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
@@ -390,6 +392,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
||||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { PayersPage } from "@/features/payers/components/payers-page";
|
import { PayersPage } from "@/features/payers/components/payers-page";
|
||||||
import { fetchPayersForUser } from "@/features/payers/queries";
|
import { fetchPayersForUser } from "@/features/payers/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { payers, avatarOptions } = await fetchPayersForUser(userId);
|
const { payers, avatarOptions } = await fetchPayersForUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RiBankCard2Line } from "@remixicon/react";
|
import { RiBankCard2Line } from "@remixicon/react";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
|
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
|
||||||
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
|
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
|
||||||
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
|
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
|
||||||
@@ -28,6 +29,7 @@ const getSingleParam = (
|
|||||||
export default async function RelatorioCartoesPage({
|
export default async function RelatorioCartoesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import type { Category } from "@/db/schema";
|
import type { Category } from "@/db/schema";
|
||||||
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
|
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
|
||||||
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
|
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
|
||||||
@@ -29,6 +30,7 @@ const getSingleParam = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
// Get authenticated user
|
// Get authenticated user
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
|
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
|
||||||
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
|
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
|
||||||
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
|
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
|
||||||
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
|
|||||||
export default async function TopEstabelecimentosPage({
|
export default async function TopEstabelecimentosPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = await fetchInstallmentAnalysis(user.id);
|
const data = await fetchInstallmentAnalysis(user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RiArrowRightSLine } from "@remixicon/react";
|
import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
||||||
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
||||||
@@ -11,6 +12,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
|
|||||||
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
|
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
|
||||||
import { fetchSettingsPageData } from "@/features/settings/queries";
|
import { fetchSettingsPageData } from "@/features/settings/queries";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
import { Card } from "@/shared/components/ui/card";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
@@ -64,12 +67,13 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Preferências</h2>
|
<h2 className="text-xl font-medium mb-1">Preferências</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Personalize sua experiência no OpenMonetis ajustando as
|
Personalize sua experiência no OpenMonetis ajustando as
|
||||||
configurações de acordo com suas necessidades.
|
configurações de acordo com suas necessidades.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<PreferencesForm
|
<PreferencesForm
|
||||||
statementNoteAsColumn={
|
statementNoteAsColumn={
|
||||||
userPreferences?.statementNoteAsColumn ?? false
|
userPreferences?.statementNoteAsColumn ?? false
|
||||||
@@ -77,25 +81,46 @@ export default async function Page() {
|
|||||||
transactionsColumnOrder={
|
transactionsColumnOrder={
|
||||||
userPreferences?.transactionsColumnOrder ?? null
|
userPreferences?.transactionsColumnOrder ?? null
|
||||||
}
|
}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="companion" className="mt-4">
|
<TabsContent value="companion" className="mt-4">
|
||||||
<CompanionTab tokens={userApiTokens} />
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h2 className="text-xl font-medium">OpenMonetis Companion</h2>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
||||||
|
<RiAndroidLine className="h-3 w-3" />
|
||||||
|
Android
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Capture notificações de transações dos seus apps de banco
|
||||||
|
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
|
||||||
|
caixa de entrada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<CompanionTab tokens={userApiTokens} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="nome" className="mt-4">
|
<TabsContent value="nome" className="mt-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
|
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
||||||
ser exibido em diferentes seções do app e em comunicações.
|
ser exibido em diferentes seções do app e em comunicações.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<UpdateNameForm currentName={userName} />
|
<UpdateNameForm currentName={userName} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -105,12 +130,13 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
|
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Defina uma nova senha para sua conta. Guarde-a em local
|
Defina uma nova senha para sua conta. Guarde-a em local
|
||||||
seguro.
|
seguro.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<UpdatePasswordForm authProvider={authProvider} />
|
<UpdatePasswordForm authProvider={authProvider} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -120,12 +146,13 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
|
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Passkeys permitem login sem senha, usando biometria (Face ID,
|
Passkeys permitem login sem senha, usando biometria (Face ID,
|
||||||
Touch ID, Windows Hello) ou chaves de segurança.
|
Touch ID, Windows Hello) ou chaves de segurança.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<PasskeysForm />
|
<PasskeysForm />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -135,13 +162,14 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
|
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Atualize o e-mail associado à sua conta. Você precisará
|
Atualize o e-mail associado à sua conta. Você precisará
|
||||||
confirmar os links enviados para o novo e também para o e-mail
|
confirmar os links enviados para o novo e também para o e-mail
|
||||||
atual (quando aplicável) para concluir a alteração.
|
atual (quando aplicável) para concluir a alteração.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<UpdateEmailForm
|
<UpdateEmailForm
|
||||||
currentEmail={userEmail}
|
currentEmail={userEmail}
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
@@ -154,14 +182,15 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1 text-destructive">
|
<h2 className="text-xl font-medium mb-1 text-destructive">
|
||||||
Ações perigosas
|
Ações perigosas
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||||
ou excluir sua conta inteira de forma irreversível.
|
ou excluir sua conta inteira de forma irreversível.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<DeleteAccountForm />
|
<DeleteAccountForm />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
||||||
import {
|
import {
|
||||||
buildOptionSets,
|
buildOptionSets,
|
||||||
@@ -7,6 +8,7 @@ import { fetchTransactionFilterSources } from "@/features/transactions/queries";
|
|||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const filterSources = await fetchTransactionFilterSources(userId);
|
const filterSources = await fetchTransactionFilterSources(userId);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||||
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
}}
|
}}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
extraFeatures,
|
extraFeatures,
|
||||||
getMetricsItems,
|
getMetricsItems,
|
||||||
mainFeatures,
|
mainFeatures,
|
||||||
navbarActionClassName,
|
|
||||||
navLinks,
|
navLinks,
|
||||||
pwaHighlights,
|
pwaHighlights,
|
||||||
stackItems,
|
stackItems,
|
||||||
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
|
|||||||
import { fetchGitHubStats } from "@/features/landing/queries";
|
import { fetchGitHubStats } from "@/features/landing/queries";
|
||||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||||
import { Logo } from "@/shared/components/logo";
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
@@ -50,65 +50,60 @@ export default async function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary">
|
<NavbarShell>
|
||||||
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between">
|
{/* Center Navigation Links */}
|
||||||
<Logo variant="compact" invertTextOnDark={false} />
|
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
||||||
|
{navLinks.map(({ href, label }) => (
|
||||||
|
<a
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Center Navigation Links */}
|
<nav className="ml-auto flex items-center gap-2 md:gap-3">
|
||||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
<AnimatedThemeToggler variant="navbar" />
|
||||||
{navLinks.map(({ href, label }) => (
|
{!isPublicDomain &&
|
||||||
<a
|
(session?.user ? (
|
||||||
key={href}
|
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||||
href={href}
|
<Button
|
||||||
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
{label}
|
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
|
||||||
</a>
|
>
|
||||||
))}
|
Dashboard
|
||||||
</nav>
|
</Button>
|
||||||
|
</Link>
|
||||||
<nav className="flex items-center gap-2 md:gap-3">
|
) : (
|
||||||
<AnimatedThemeToggler className={navbarActionClassName} />
|
<div className="hidden md:flex items-center gap-2">
|
||||||
{!isPublicDomain &&
|
<Link href="/login">
|
||||||
(session?.user ? (
|
|
||||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
|
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
|
||||||
>
|
>
|
||||||
Dashboard
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
<Link href="/signup">
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<Button
|
||||||
<Link href="/login">
|
size="sm"
|
||||||
<Button
|
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
Começar
|
||||||
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
|
</Button>
|
||||||
>
|
</Link>
|
||||||
Entrar
|
</div>
|
||||||
</Button>
|
))}
|
||||||
</Link>
|
<MobileNav
|
||||||
<Link href="/signup">
|
isPublicDomain={isPublicDomain}
|
||||||
<Button
|
isLoggedIn={!!session?.user}
|
||||||
size="sm"
|
/>
|
||||||
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
|
</nav>
|
||||||
>
|
</NavbarShell>
|
||||||
Começar
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<MobileNav
|
|
||||||
isPublicDomain={isPublicDomain}
|
|
||||||
isLoggedIn={!!session?.user}
|
|
||||||
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||||
@@ -131,7 +126,7 @@ export default async function Page() {
|
|||||||
Projeto Open Source
|
Projeto Open Source
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
|
||||||
Suas finanças,
|
Suas finanças,
|
||||||
<span className="text-primary"> do seu jeito</span>
|
<span className="text-primary"> do seu jeito</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -212,7 +207,7 @@ export default async function Page() {
|
|||||||
className="flex flex-col items-center text-center gap-1.5"
|
className="flex flex-col items-center text-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Icon className="size-5" style={{ color: colorVar }} />
|
<Icon className="size-5" style={{ color: colorVar }} />
|
||||||
<span className="text-2xl md:text-3xl font-bold">
|
<span className="text-2xl md:text-3xl font-medium">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs md:text-sm text-muted-foreground">
|
<span className="text-xs md:text-sm text-muted-foreground">
|
||||||
@@ -234,7 +229,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Conheça as telas
|
Conheça as telas
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Veja o que você pode fazer
|
Veja o que você pode fazer
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -259,7 +254,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
O que tem aqui
|
O que tem aqui
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Funcionalidades que importam
|
Funcionalidades que importam
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -287,7 +282,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
@@ -351,7 +346,7 @@ export default async function Page() {
|
|||||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||||
Mobile
|
Mobile
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Use o OpenMonetis no celular sem perder o fluxo
|
Use o OpenMonetis no celular sem perder o fluxo
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -389,7 +384,7 @@ export default async function Page() {
|
|||||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||||
PWA instalável
|
PWA instalável
|
||||||
</Badge>
|
</Badge>
|
||||||
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
|
<h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
|
||||||
Leve o OpenMonetis para a tela inicial
|
Leve o OpenMonetis para a tela inicial
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||||
@@ -435,7 +430,7 @@ export default async function Page() {
|
|||||||
Companion Android
|
Companion Android
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
|
<h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
|
||||||
Capture, envie e revise no mesmo fluxo
|
Capture, envie e revise no mesmo fluxo
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||||
@@ -446,7 +441,7 @@ export default async function Page() {
|
|||||||
{companionSteps.map((step, index) => (
|
{companionSteps.map((step, index) => (
|
||||||
<li key={step.title} className="flex items-start gap-3">
|
<li key={step.title} className="flex items-start gap-3">
|
||||||
<span
|
<span
|
||||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
||||||
color: step.colorVar,
|
color: step.colorVar,
|
||||||
@@ -534,7 +529,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Stack técnica
|
Stack técnica
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
O que roda por baixo
|
O que roda por baixo
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -561,7 +556,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
||||||
@@ -587,7 +582,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Como usar
|
Como usar
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Rode no seu computador
|
Rode no seu computador
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||||
@@ -622,7 +617,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Para quem é?
|
Para quem é?
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Feito para quem gosta de controle
|
Feito para quem gosta de controle
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||||
@@ -649,7 +644,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-1">{item.title}</h3>
|
<h3 className="font-medium mb-1">{item.title}</h3>
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -669,7 +664,7 @@ export default async function Page() {
|
|||||||
<div className="max-w-8xl mx-auto px-4">
|
<div className="max-w-8xl mx-auto px-4">
|
||||||
<AnimateOnScroll>
|
<AnimateOnScroll>
|
||||||
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Pronto para testar?
|
Pronto para testar?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
||||||
@@ -720,7 +715,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3>
|
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
|
||||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
@@ -754,7 +749,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3>
|
<h3 className="font-medium mb-3 md:mb-4">Companion</h3>
|
||||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
42
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
42
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { attachments } from "@/db/schema";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||||
|
) {
|
||||||
|
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not found" },
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await createPresignedGetUrl(row.fileKey);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ url },
|
||||||
|
{
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -16,14 +16,17 @@ const createTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
await connection();
|
||||||
|
|
||||||
|
// Verificar autenticação via sessão web
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar body
|
// Validar body
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, deviceId } = createTokenSchema.parse(body);
|
const { name, deviceId } = createTokenSchema.parse(body);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -10,16 +10,19 @@ interface RouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||||
|
await connection();
|
||||||
|
|
||||||
|
const { tokenId } = await params;
|
||||||
|
|
||||||
|
// Verificar autenticação via sessão web
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tokenId } = await params;
|
|
||||||
|
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se token pertence ao usuário
|
// Verificar se token pertence ao usuário
|
||||||
const token = await db.query.apiTokens.findFirst({
|
const token = await db.query.apiTokens.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
await connection();
|
||||||
|
|
||||||
|
// Verificar autenticação via sessão web
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buscar tokens ativos do usuário
|
// Buscar tokens ativos do usuário
|
||||||
const activeTokens = await db
|
const activeTokens = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -86,12 +86,10 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { items } = inboxBatchSchema.parse(body);
|
const { items } = inboxBatchSchema.parse(body);
|
||||||
|
|
||||||
// Processar cada item
|
// Processar todos os itens em paralelo
|
||||||
const results: BatchResult[] = [];
|
const settled = await Promise.allSettled(
|
||||||
|
items.map((item) =>
|
||||||
for (const item of items) {
|
db
|
||||||
try {
|
|
||||||
const [inserted] = await db
|
|
||||||
.insert(inboxItems)
|
.insert(inboxItems)
|
||||||
.values({
|
.values({
|
||||||
userId: tokenRecord.userId,
|
userId: tokenRecord.userId,
|
||||||
@@ -104,22 +102,26 @@ export async function POST(request: Request) {
|
|||||||
parsedAmount: item.parsedAmount?.toString(),
|
parsedAmount: item.parsedAmount?.toString(),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})
|
})
|
||||||
.returning({ id: inboxItems.id });
|
.returning({ id: inboxItems.id }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
results.push({
|
const results: BatchResult[] = settled.map((result, i) => {
|
||||||
clientId: item.clientId,
|
const item = items[i];
|
||||||
serverId: inserted.id,
|
if (result.status === "fulfilled") {
|
||||||
|
return {
|
||||||
|
clientId: item?.clientId,
|
||||||
|
serverId: result.value[0]?.id,
|
||||||
success: true,
|
success: true,
|
||||||
});
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error("[API] Error processing batch item:", error);
|
|
||||||
results.push({
|
|
||||||
clientId: item.clientId,
|
|
||||||
success: false,
|
|
||||||
error: "Erro ao processar notificação",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
console.error("[API] Error processing batch item:", result.reason);
|
||||||
|
return {
|
||||||
|
clientId: item?.clientId,
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao processar notificação",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Atualizar último uso do token
|
// Atualizar último uso do token
|
||||||
const clientIp =
|
const clientIp =
|
||||||
|
|||||||
34
src/app/api/insights/saved/route.ts
Normal file
34
src/app/api/insights/saved/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
fetchSavedInsights,
|
||||||
|
savedInsightsPeriodSchema,
|
||||||
|
} from "@/features/insights/queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const period = new URL(request.url).searchParams.get("period") ?? "";
|
||||||
|
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
|
||||||
|
|
||||||
|
if (!validatedPeriod.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await getUserId();
|
||||||
|
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
|
||||||
|
|
||||||
|
return NextResponse.json(insights, {
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ transactionId: string }> },
|
||||||
|
) {
|
||||||
|
const [userId, { transactionId }] = await Promise.all([getUserId(), params]);
|
||||||
|
const attachments = await fetchTransactionAttachments(userId, transactionId);
|
||||||
|
|
||||||
|
return NextResponse.json(attachments, {
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ seriesId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const [userId, { seriesId }] = await Promise.all([getUserId(), params]);
|
||||||
|
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
|
||||||
|
|
||||||
|
return NextResponse.json(anticipations, {
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar histórico de antecipações:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Erro ao carregar histórico de antecipações.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: PRIVATE_RESPONSE_HEADERS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Analytics } from "@vercel/analytics/next";
|
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { QueryProvider } from "@/shared/components/providers/query-provider";
|
||||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -22,20 +22,27 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${america.variable} ${america.className}`}
|
className={`${america.variable} ${america.className} `}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://umami.felipecoutinho.com/script.js"
|
||||||
|
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
||||||
|
data-domains="openmonetis.com"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
{children}
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
<Suspense>{children}</Suspense>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Analytics />
|
|
||||||
<SpeedInsights />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
|
||||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
|
||||||
: "https://openmonetis.com";
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
@@ -21,15 +17,15 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
"/notes",
|
"/notes",
|
||||||
"/insights",
|
"/insights",
|
||||||
"/calendar",
|
"/calendar",
|
||||||
"/consultor",
|
"/attachments",
|
||||||
"/settings",
|
"/settings",
|
||||||
"/reports",
|
"/reports",
|
||||||
"/inbox",
|
"/inbox",
|
||||||
"/login",
|
"/login",
|
||||||
|
"/signup",
|
||||||
"/api/",
|
"/api/",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: `${BASE_URL}/sitemap.xml`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/db/schema.ts
151
src/db/schema.ts
@@ -132,14 +132,14 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
|||||||
statementNoteAsColumn: boolean("extrato_note_as_column")
|
statementNoteAsColumn: boolean("extrato_note_as_column")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
systemFont: text("system_font").notNull().default("ai-sans"),
|
|
||||||
moneyFont: text("money_font").notNull().default("ai-sans"),
|
|
||||||
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
||||||
string[] | null
|
string[] | null
|
||||||
>(),
|
>(),
|
||||||
|
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
|
||||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
myAccountsShowExcluded?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
createdAt: timestamp("created_at", {
|
createdAt: timestamp("created_at", {
|
||||||
mode: "date",
|
mode: "date",
|
||||||
@@ -527,6 +527,40 @@ export const inboxItems = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const dashboardNotificationStates = pgTable(
|
||||||
|
"dashboard_notification_states",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
notificationKey: text("notification_key").notNull(),
|
||||||
|
fingerprint: text("fingerprint").notNull(),
|
||||||
|
readAt: timestamp("read_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
archivedAt: timestamp("archived_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdNotificationKeyUnique: uniqueIndex(
|
||||||
|
"dashboard_notification_states_user_id_key_unique",
|
||||||
|
).on(table.userId, table.notificationKey),
|
||||||
|
userIdArchivedAtIdx: index(
|
||||||
|
"dashboard_notification_states_user_id_archived_idx",
|
||||||
|
).on(table.userId, table.archivedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const installmentAnticipations = pgTable(
|
export const installmentAnticipations = pgTable(
|
||||||
"antecipacoes_parcelas",
|
"antecipacoes_parcelas",
|
||||||
{
|
{
|
||||||
@@ -815,32 +849,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const transactionsRelations = relations(transactions, ({ one }) => ({
|
export const transactionsRelations = relations(
|
||||||
user: one(user, {
|
transactions,
|
||||||
fields: [transactions.userId],
|
({ one, many }) => ({
|
||||||
references: [user.id],
|
user: one(user, {
|
||||||
|
fields: [transactions.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
card: one(cards, {
|
||||||
|
fields: [transactions.cardId],
|
||||||
|
references: [cards.id],
|
||||||
|
}),
|
||||||
|
financialAccount: one(financialAccounts, {
|
||||||
|
fields: [transactions.accountId],
|
||||||
|
references: [financialAccounts.id],
|
||||||
|
}),
|
||||||
|
category: one(categories, {
|
||||||
|
fields: [transactions.categoryId],
|
||||||
|
references: [categories.id],
|
||||||
|
}),
|
||||||
|
payer: one(payers, {
|
||||||
|
fields: [transactions.payerId],
|
||||||
|
references: [payers.id],
|
||||||
|
}),
|
||||||
|
anticipation: one(installmentAnticipations, {
|
||||||
|
fields: [transactions.anticipationId],
|
||||||
|
references: [installmentAnticipations.id],
|
||||||
|
}),
|
||||||
|
transactionAttachments: many(transactionAttachments),
|
||||||
}),
|
}),
|
||||||
card: one(cards, {
|
);
|
||||||
fields: [transactions.cardId],
|
|
||||||
references: [cards.id],
|
|
||||||
}),
|
|
||||||
financialAccount: one(financialAccounts, {
|
|
||||||
fields: [transactions.accountId],
|
|
||||||
references: [financialAccounts.id],
|
|
||||||
}),
|
|
||||||
category: one(categories, {
|
|
||||||
fields: [transactions.categoryId],
|
|
||||||
references: [categories.id],
|
|
||||||
}),
|
|
||||||
payer: one(payers, {
|
|
||||||
fields: [transactions.payerId],
|
|
||||||
references: [payers.id],
|
|
||||||
}),
|
|
||||||
anticipation: one(installmentAnticipations, {
|
|
||||||
fields: [transactions.anticipationId],
|
|
||||||
references: [installmentAnticipations.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const installmentAnticipationsRelations = relations(
|
export const installmentAnticipationsRelations = relations(
|
||||||
installmentAnticipations,
|
installmentAnticipations,
|
||||||
@@ -864,6 +902,40 @@ export const installmentAnticipationsRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===================== ATTACHMENTS =====================
|
||||||
|
|
||||||
|
export const attachments = pgTable("anexos", {
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
fileKey: text("chave_arquivo").notNull().unique(),
|
||||||
|
fileName: text("nome_arquivo").notNull(),
|
||||||
|
fileSize: integer("tamanho_bytes").notNull(),
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transactionAttachments = pgTable(
|
||||||
|
"lancamento_anexos",
|
||||||
|
{
|
||||||
|
transactionId: uuid("lancamento_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => transactions.id, { onDelete: "cascade" }),
|
||||||
|
attachmentId: uuid("anexo_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => attachments.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.transactionId, table.attachmentId] }),
|
||||||
|
attachmentIdIdx: index("lancamento_anexos_anexo_id_idx").on(
|
||||||
|
table.attachmentId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const importCategoryMappings = pgTable(
|
export const importCategoryMappings = pgTable(
|
||||||
"import_category_mappings",
|
"import_category_mappings",
|
||||||
{
|
{
|
||||||
@@ -907,3 +979,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
|
|||||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||||
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
||||||
|
|
||||||
|
export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [attachments.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
transactionAttachments: many(transactionAttachments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const transactionAttachmentsRelations = relations(
|
||||||
|
transactionAttachments,
|
||||||
|
({ one }) => ({
|
||||||
|
transaction: one(transactions, {
|
||||||
|
fields: [transactionAttachments.transactionId],
|
||||||
|
references: [transactions.id],
|
||||||
|
}),
|
||||||
|
attachment: one(attachments, {
|
||||||
|
fields: [transactionAttachments.attachmentId],
|
||||||
|
references: [attachments.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Attachment = typeof attachments.$inferSelect;
|
||||||
|
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ export function AccountCard({
|
|||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
|
||||||
{accountName}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function AccountStatementCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
<h2 className="truncate text-sm font-medium text-foreground">
|
||||||
{accountName}
|
{accountName}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -86,12 +86,12 @@ export function AccountStatementCard({
|
|||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={currentBalance}
|
amount={currentBalance}
|
||||||
className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]"
|
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={getAccountStatusBadgeVariant(status)}
|
variant={getAccountStatusBadgeVariant(status)}
|
||||||
className="text-[11px]"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -107,7 +107,7 @@ export function AccountStatementCard({
|
|||||||
label="Saldo inicial"
|
label="Saldo inicial"
|
||||||
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{formatCurrency(openingBalance)}
|
{formatCurrency(openingBalance)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -116,7 +116,7 @@ export function AccountStatementCard({
|
|||||||
label="Entradas"
|
label="Entradas"
|
||||||
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-success">
|
<span className="text-sm font-medium text-success">
|
||||||
{formatCurrency(totalIncomes)}
|
{formatCurrency(totalIncomes)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -125,7 +125,7 @@ export function AccountStatementCard({
|
|||||||
label="Saídas"
|
label="Saídas"
|
||||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-destructive">
|
<span className="text-sm font-medium text-destructive">
|
||||||
{formatCurrency(totalExpenses)}
|
{formatCurrency(totalExpenses)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -136,7 +136,7 @@ export function AccountStatementCard({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold",
|
"text-sm font-medium",
|
||||||
resultado >= 0 ? "text-success" : "text-destructive",
|
resultado >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
209
src/features/attachments/components/attachment-grid-item.tsx
Normal file
209
src/features/attachments/components/attachment-grid-item.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
import { formatBytes } from "@/shared/utils/number";
|
||||||
|
|
||||||
|
interface PdfCanvasProps {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfCanvas({ url }: PdfCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLocked(false);
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const pdfjsLib = await import("pdfjs-dist");
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||||
|
|
||||||
|
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
|
||||||
|
try {
|
||||||
|
pdf = await pdfjsLib.getDocument(url).promise;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { name?: string }).name === "PasswordException") {
|
||||||
|
if (!cancelled) setLocked(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || cancelled) return;
|
||||||
|
|
||||||
|
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = containerWidth / viewport.width;
|
||||||
|
const scaled = page.getViewport({ scale });
|
||||||
|
|
||||||
|
canvas.width = scaled.width;
|
||||||
|
canvas.height = scaled.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
|
||||||
|
.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
render().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
||||||
|
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/60">
|
||||||
|
PDF Protegido
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentGridItemProps {
|
||||||
|
attachment: AttachmentForPeriod;
|
||||||
|
url?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
isLoadingDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentGridItem({
|
||||||
|
attachment,
|
||||||
|
url,
|
||||||
|
onClick,
|
||||||
|
onDetails,
|
||||||
|
isLoadingDetails = false,
|
||||||
|
}: AttachmentGridItemProps) {
|
||||||
|
const isPdf = attachment.mimeType === "application/pdf";
|
||||||
|
const isImage = attachment.mimeType.startsWith("image/");
|
||||||
|
const amount = Number.parseFloat(attachment.transactionAmount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Conteúdo do thumbnail */}
|
||||||
|
{isImage && url && (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
|
||||||
|
unoptimized
|
||||||
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && !url && (
|
||||||
|
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
|
||||||
|
)}
|
||||||
|
{isPdf && url && <PdfCanvas url={url} />}
|
||||||
|
{isPdf && !url && (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
|
||||||
|
<RiFilePdf2Line className="size-14 text-red-400/60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isImage && !isPdf && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||||
|
<RiFileLine className="size-14 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay no hover */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Informações */}
|
||||||
|
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
|
||||||
|
{/* Nome do arquivo + tipo */}
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<div className="shrink-0 gap-0.5 text-xs opacity-60">
|
||||||
|
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
|
||||||
|
{isImage && <RiImageLine className="size-4 text-blue-500" />}
|
||||||
|
{!isPdf && !isImage && <RiFileLine className="size-4" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate text-sm font-medium leading-tight text-foreground">
|
||||||
|
{attachment.fileName}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
{attachment.fileName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(attachment.purchaseDate)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Transação e Valor */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{attachment.transactionName}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{attachment.transactionName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Tamanho + Botão Detalhes */}
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/70">
|
||||||
|
{formatBytes(attachment.fileSize)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDetails}
|
||||||
|
disabled={isLoadingDetails}
|
||||||
|
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/features/attachments/components/attachment-preview.tsx
Normal file
200
src/features/attachments/components/attachment-preview.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiCloseLine,
|
||||||
|
RiDownloadLine,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
|
||||||
|
interface AttachmentPreviewProps {
|
||||||
|
attachments: AttachmentForPeriod[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPreview({
|
||||||
|
attachments,
|
||||||
|
selectedIndex,
|
||||||
|
onClose,
|
||||||
|
}: AttachmentPreviewProps) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||||
|
const open = selectedIndex >= 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
|
||||||
|
if (e.key === "ArrowRight")
|
||||||
|
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open, attachments.length]);
|
||||||
|
|
||||||
|
const attachment = attachments[currentIndex];
|
||||||
|
const attachmentId = attachment?.attachmentId;
|
||||||
|
const {
|
||||||
|
data: previewUrl,
|
||||||
|
isLoading: isPreviewLoading,
|
||||||
|
isError: isPreviewError,
|
||||||
|
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
|
||||||
|
|
||||||
|
if (!attachment) return null;
|
||||||
|
|
||||||
|
const isPdf = attachment.mimeType === "application/pdf";
|
||||||
|
const isImage = attachment.mimeType.startsWith("image/");
|
||||||
|
const hasPrev = currentIndex > 0;
|
||||||
|
const hasNext = currentIndex < attachments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-row items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<DialogTitle
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
title={attachment.transactionName}
|
||||||
|
>
|
||||||
|
{attachment.transactionName}
|
||||||
|
</DialogTitle>
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-muted-foreground"
|
||||||
|
title={attachment.fileName}
|
||||||
|
>
|
||||||
|
{attachment.fileName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{attachments.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setCurrentIndex((i) => i - 1)}
|
||||||
|
title="Anterior (←)"
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="select-none text-xs text-muted-foreground tabular-nums">
|
||||||
|
{currentIndex + 1} / {attachments.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setCurrentIndex((i) => i + 1)}
|
||||||
|
title="Próximo (→)"
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!previewUrl}
|
||||||
|
asChild={!!previewUrl}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<a
|
||||||
|
href={previewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={attachment.fileName}
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!previewUrl}
|
||||||
|
asChild={!!previewUrl}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<a href={previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon">
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
|
{isPreviewLoading && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPreviewError && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||||
|
Não foi possível carregar a visualização deste anexo.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPdf && previewUrl && (
|
||||||
|
<iframe
|
||||||
|
key={attachment.attachmentId}
|
||||||
|
src={previewUrl}
|
||||||
|
className="h-full w-full border-0 bg-background"
|
||||||
|
title={attachment.fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && previewUrl && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
key={attachment.attachmentId}
|
||||||
|
src={previewUrl}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/features/attachments/components/attachments-page.tsx
Normal file
275
src/features/attachments/components/attachments-page.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiAttachmentLine,
|
||||||
|
RiFilePdf2Line,
|
||||||
|
RiImageLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type React from "react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
|
||||||
|
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
|
||||||
|
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
|
||||||
|
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
|
||||||
|
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
|
||||||
|
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
|
||||||
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
|
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||||
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
|
import PageDescription from "@/shared/components/page-description";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
type FilterType = "all" | "images" | "pdfs";
|
||||||
|
|
||||||
|
function AttachmentGridItemWithUrl({
|
||||||
|
attachment,
|
||||||
|
onClick,
|
||||||
|
onDetails,
|
||||||
|
isLoadingDetails,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentForPeriod;
|
||||||
|
onClick: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
isLoadingDetails: boolean;
|
||||||
|
}) {
|
||||||
|
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<AttachmentGridItem
|
||||||
|
attachment={attachment}
|
||||||
|
url={url ?? undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onDetails={onDetails}
|
||||||
|
isLoadingDetails={isLoadingDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: {
|
||||||
|
value: FilterType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: "Todos",
|
||||||
|
icon: <RiAttachmentLine className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "images",
|
||||||
|
label: "Imagens",
|
||||||
|
icon: <RiImageLine className="size-3.5 text-blue-500" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pdfs",
|
||||||
|
label: "PDFs",
|
||||||
|
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AttachmentsPageProps {
|
||||||
|
attachments: AttachmentForPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [filter, setFilter] = useState<FilterType>("all");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [transactionDetails, setTransactionDetails] =
|
||||||
|
useState<TransactionItem | null>(null);
|
||||||
|
const [loadingTransactionId, setLoadingTransactionId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Edit dialog state
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [transactionToEdit, setTransactionToEdit] =
|
||||||
|
useState<TransactionItem | null>(null);
|
||||||
|
const [dialogOptions, setDialogOptions] =
|
||||||
|
useState<TransactionDialogOptions | null>(null);
|
||||||
|
|
||||||
|
const filteredAttachments = attachments.filter((a) => {
|
||||||
|
if (filter === "images") return a.mimeType.startsWith("image/");
|
||||||
|
if (filter === "pdfs") return a.mimeType === "application/pdf";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageCount = attachments.filter((a) =>
|
||||||
|
a.mimeType.startsWith("image/"),
|
||||||
|
).length;
|
||||||
|
const pdfCount = attachments.filter(
|
||||||
|
(a) => a.mimeType === "application/pdf",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const counts: Record<FilterType, number> = {
|
||||||
|
all: attachments.length,
|
||||||
|
images: imageCount,
|
||||||
|
pdfs: pdfCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSelect(attachment: AttachmentForPeriod) {
|
||||||
|
const idx = filteredAttachments.findIndex(
|
||||||
|
(a) =>
|
||||||
|
a.attachmentId === attachment.attachmentId &&
|
||||||
|
a.transactionId === attachment.transactionId,
|
||||||
|
);
|
||||||
|
setSelectedIndex(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetails(transactionId: string) {
|
||||||
|
setLoadingTransactionId(transactionId);
|
||||||
|
startTransition(async () => {
|
||||||
|
const transaction = await fetchTransactionByIdAction(transactionId);
|
||||||
|
setLoadingTransactionId(null);
|
||||||
|
if (transaction) setTransactionDetails(transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(transaction: TransactionItem) {
|
||||||
|
setTransactionToEdit(transaction);
|
||||||
|
startTransition(async () => {
|
||||||
|
const options = await fetchTransactionDialogOptionsAction();
|
||||||
|
setDialogOptions(options);
|
||||||
|
setEditOpen(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<PageDescription
|
||||||
|
icon={<RiAttachmentLine className="size-5" />}
|
||||||
|
title="Anexos"
|
||||||
|
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MonthNavigation />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<div className="flex w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhum anexo neste mês"
|
||||||
|
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header: filtros + contagem */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{filteredAttachments.length}{" "}
|
||||||
|
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
|
||||||
|
{filter !== "all" &&
|
||||||
|
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||||
|
{FILTERS.map(({ value, label, icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(value);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
filter === value
|
||||||
|
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(filter !== value && "opacity-60")}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{label}{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
filter === value ? "opacity-80" : "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
({counts[value]})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredAttachments.length === 0 ? (
|
||||||
|
<div className="flex w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhum anexo encontrado"
|
||||||
|
description="Não há anexos do tipo selecionado neste mês."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{filteredAttachments.map((attachment) => (
|
||||||
|
<AttachmentGridItemWithUrl
|
||||||
|
key={`${attachment.attachmentId}-${attachment.transactionId}`}
|
||||||
|
attachment={attachment}
|
||||||
|
onClick={() => handleSelect(attachment)}
|
||||||
|
onDetails={() => handleDetails(attachment.transactionId)}
|
||||||
|
isLoadingDetails={
|
||||||
|
isPending &&
|
||||||
|
loadingTransactionId === attachment.transactionId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AttachmentPreview
|
||||||
|
attachments={filteredAttachments}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onClose={() => setSelectedIndex(-1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransactionDetailsDialog
|
||||||
|
open={!!transactionDetails}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTransactionDetails(null);
|
||||||
|
}}
|
||||||
|
transaction={transactionDetails}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dialogOptions && transactionToEdit && (
|
||||||
|
<TransactionDialog
|
||||||
|
mode="update"
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setEditOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setTransactionToEdit(null);
|
||||||
|
setDialogOptions(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
transaction={transactionToEdit}
|
||||||
|
payerOptions={dialogOptions.payerOptions}
|
||||||
|
splitPayerOptions={dialogOptions.splitPayerOptions}
|
||||||
|
defaultPayerId={dialogOptions.defaultPayerId}
|
||||||
|
accountOptions={dialogOptions.accountOptions}
|
||||||
|
cardOptions={dialogOptions.cardOptions}
|
||||||
|
categoryOptions={dialogOptions.categoryOptions}
|
||||||
|
estabelecimentos={dialogOptions.estabelecimentos}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/features/attachments/hooks/use-attachment-url.ts
Normal file
54
src/features/attachments/hooks/use-attachment-url.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchJson } from "@/shared/lib/fetch-json";
|
||||||
|
|
||||||
|
const ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;
|
||||||
|
|
||||||
|
export const attachmentUrlQueryKey = (attachmentId: string) =>
|
||||||
|
["attachments", "url", attachmentId] as const;
|
||||||
|
|
||||||
|
export function useAttachmentUrlQuery(attachmentId: string, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: attachmentUrlQueryKey(attachmentId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const payload = await fetchJson<{ url: string }>(
|
||||||
|
`/api/attachments/${attachmentId}/presign`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return payload.url;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(attachmentId),
|
||||||
|
staleTime: ATTACHMENT_URL_STALE_TIME,
|
||||||
|
gcTime: ATTACHMENT_URL_STALE_TIME * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAttachmentUrl(attachmentId: string) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void attachmentId;
|
||||||
|
setIsVisible(false);
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries[0].isIntersecting) return;
|
||||||
|
observer.disconnect();
|
||||||
|
setIsVisible(true);
|
||||||
|
},
|
||||||
|
{ rootMargin: "150px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [attachmentId]);
|
||||||
|
|
||||||
|
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
|
||||||
|
|
||||||
|
return { url: url ?? null, containerRef };
|
||||||
|
}
|
||||||
70
src/features/attachments/queries.ts
Normal file
70
src/features/attachments/queries.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
import {
|
||||||
|
attachments,
|
||||||
|
categories,
|
||||||
|
transactionAttachments,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
|
export type AttachmentForPeriod = {
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
transactionId: string;
|
||||||
|
transactionName: string;
|
||||||
|
transactionAmount: string;
|
||||||
|
transactionPeriod: string;
|
||||||
|
purchaseDate: Date;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAttachmentsForPeriod(
|
||||||
|
userId: string,
|
||||||
|
period: string,
|
||||||
|
): Promise<AttachmentForPeriod[]> {
|
||||||
|
"use cache";
|
||||||
|
cacheTag(`dashboard-${userId}`);
|
||||||
|
cacheLife({ revalidate: 3 });
|
||||||
|
|
||||||
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
|
if (!adminPayerId) return [];
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
attachmentId: attachments.id,
|
||||||
|
fileName: attachments.fileName,
|
||||||
|
fileSize: attachments.fileSize,
|
||||||
|
mimeType: attachments.mimeType,
|
||||||
|
transactionId: transactions.id,
|
||||||
|
transactionName: transactions.name,
|
||||||
|
transactionAmount: transactions.amount,
|
||||||
|
transactionPeriod: transactions.period,
|
||||||
|
purchaseDate: transactions.purchaseDate,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryIcon: categories.icon,
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
eq(attachments.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
transactions,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.transactionId, transactions.id),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
eq(transactions.payerId, adminPayerId),
|
||||||
|
eq(transactions.period, period),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ interface AuthHeaderProps {
|
|||||||
|
|
||||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2")}>
|
<div className={cn("flex flex-col gap-2.5")}>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
|
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
89
src/features/auth/components/auth-sidebar-invoices-mock.tsx
Normal file
89
src/features/auth/components/auth-sidebar-invoices-mock.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
|
||||||
|
type MockInvoice = {
|
||||||
|
cardName: string;
|
||||||
|
logo: string;
|
||||||
|
amount: number;
|
||||||
|
dueLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_INVOICES: MockInvoice[] = [
|
||||||
|
{
|
||||||
|
cardName: "Nubank",
|
||||||
|
logo: "nubank.png",
|
||||||
|
amount: 1898,
|
||||||
|
dueLabel: "Vence hoje",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardName: "Itaú",
|
||||||
|
logo: "itau.png",
|
||||||
|
amount: 1923,
|
||||||
|
dueLabel: "Vence amanhã",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function MockInvoiceItem({
|
||||||
|
invoice,
|
||||||
|
divider,
|
||||||
|
}: {
|
||||||
|
invoice: MockInvoice;
|
||||||
|
divider: boolean;
|
||||||
|
}) {
|
||||||
|
const logoSrc = resolveLogoSrc(invoice.logo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={divider ? "border-b border-border/60" : undefined}>
|
||||||
|
<div className="flex items-center justify-between py-2.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-0.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
{logoSrc && (
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={`Logo ${invoice.cardName}`}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{invoice.cardName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{invoice.dueLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<span className="text-sm font-medium tracking-tighter text-foreground">
|
||||||
|
{formatCurrency(invoice.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-primary">Pagar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthSidebarInvoicesMock() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card shadow-sm">
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-foreground">Faturas</span>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Resumo das faturas do período
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
{MOCK_INVOICES.map((invoice, index) => (
|
||||||
|
<MockInvoiceItem
|
||||||
|
key={invoice.cardName}
|
||||||
|
invoice={invoice}
|
||||||
|
divider={index < MOCK_INVOICES.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
|
import {
|
||||||
|
RiBankCardLine,
|
||||||
|
RiBarChart2Line,
|
||||||
|
RiShieldCheckLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { Logo } from "@/shared/components/logo";
|
import { Logo } from "@/shared/components/logo";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||||
|
import { AuthSidebarInvoicesMock } from "./auth-sidebar-invoices-mock";
|
||||||
|
|
||||||
|
function FeatureItem({
|
||||||
|
icon: Icon,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/12">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-black/55" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-black/68">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthSidebar() {
|
function AuthSidebar() {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +38,7 @@ function AuthSidebar() {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
|
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
|
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
|
||||||
<Logo
|
<Logo
|
||||||
variant="compact"
|
variant="compact"
|
||||||
@@ -22,14 +46,25 @@ function AuthSidebar() {
|
|||||||
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
|
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-sm space-y-4.5">
|
<div className="flex flex-1 items-center justify-center py-10">
|
||||||
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]">
|
<div className="w-full rotate-[1.5deg]">
|
||||||
Controle suas finanças com clareza e foco diário.
|
<AuthSidebarInvoicesMock />
|
||||||
</h2>
|
</div>
|
||||||
<p className="max-w-2xs text-sm leading-6 text-black/68">
|
</div>
|
||||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
|
||||||
um painel inteligente feito para o seu dia a dia.
|
<div className="space-y-3">
|
||||||
</p>
|
<FeatureItem
|
||||||
|
icon={RiBarChart2Line}
|
||||||
|
text="Controle de gastos por categoria"
|
||||||
|
/>
|
||||||
|
<FeatureItem
|
||||||
|
icon={RiBankCardLine}
|
||||||
|
text="Faturas e cartões centralizados"
|
||||||
|
/>
|
||||||
|
<FeatureItem
|
||||||
|
icon={RiShieldCheckLine}
|
||||||
|
text="Seus dados, sem rastreamento"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (passkeyError) {
|
if (passkeyError) {
|
||||||
setError(passkeyError.message || "Erro ao entrar com passkey.");
|
setError(
|
||||||
|
(passkeyError.message as string) || "Erro ao entrar com passkey.",
|
||||||
|
);
|
||||||
setLoadingPasskey(false);
|
setLoadingPasskey(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
</a>
|
</a>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|
||||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className={authLinkClassName}>
|
<a href="/" className={authLinkClassName}>
|
||||||
Voltar para a página inicial
|
Voltar para a página inicial
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
|||||||
</a>
|
</a>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|
||||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className={authLinkClassName}>
|
<a href="/" className={authLinkClassName}>
|
||||||
Voltar para a página inicial
|
Voltar para a página inicial
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function BudgetCard({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-base font-semibold leading-tight">
|
<h3 className="text-base font-medium leading-tight">
|
||||||
{formatCategoryName(budget)}
|
{formatCategoryName(budget)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function BudgetDialog({
|
|||||||
<div className="space-y-3 rounded-md border p-3">
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Limite atual</span>
|
<span className="text-muted-foreground">Limite atual</span>
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatCurrency(sliderValue)}
|
{formatCurrency(sliderValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function CalendarGrid({
|
|||||||
}: CalendarGridProps) {
|
}: CalendarGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
||||||
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="grid grid-cols-7 text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{WEEK_DAYS_SHORT.map((dayName) => (
|
{WEEK_DAYS_SHORT.map((dayName) => (
|
||||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
||||||
{dayName}
|
{dayName}
|
||||||
|
|||||||
@@ -110,9 +110,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
|||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{complement ? (
|
{complement ? (
|
||||||
<span
|
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
|
||||||
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
|
|
||||||
>
|
|
||||||
{complement}
|
{complement}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -153,7 +151,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold leading-none",
|
"text-sm font-medium leading-none",
|
||||||
day.isToday
|
day.isToday
|
||||||
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
||||||
: "text-foreground/90",
|
: "text-foreground/90",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const renderLancamento = (
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold leading-tight ${
|
className={`text-sm font-medium leading-tight ${
|
||||||
isPagamentoFatura && "text-success"
|
isPagamentoFatura && "text-success"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -74,7 +74,7 @@ const renderLancamento = (
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold whitespace-nowrap",
|
"text-sm font-medium whitespace-nowrap",
|
||||||
isReceita ? "text-success" : "text-foreground",
|
isReceita ? "text-success" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -103,7 +103,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<span className="text-sm font-semibold leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
{event.transaction.name}
|
{event.transaction.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
|||||||
|
|
||||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">
|
<span className="font-medium">
|
||||||
<MoneyValues amount={event.transaction.amount} />
|
<MoneyValues amount={event.transaction.amount} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +129,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<span className="text-sm font-semibold leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
Vencimento Invoice - {event.card.name}
|
Vencimento Invoice - {event.card.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +137,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
|||||||
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{event.card.totalDue !== null ? (
|
{event.card.totalDue !== null ? (
|
||||||
<span className="font-semibold">
|
<span className="font-medium">
|
||||||
<MoneyValues amount={event.card.totalDue} />
|
<MoneyValues amount={event.card.totalDue} />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function CardItem({
|
|||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
<h3 className="truncate text-sm font-medium text-foreground sm:text-base">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
{note ? (
|
{note ? (
|
||||||
@@ -188,13 +188,13 @@ export function CardItem({
|
|||||||
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||||
<span>
|
<span>
|
||||||
Fecha dia{" "}
|
Fecha dia{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatDay(closingDay)}
|
{formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Vence dia{" "}
|
Vence dia{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatDay(dueDay)}
|
{formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -206,7 +206,7 @@ export function CardItem({
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
<MoneyValues amount={metrics[0].value} />
|
<MoneyValues amount={metrics[0].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
@@ -215,7 +215,7 @@ export function CardItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
<MoneyValues amount={metrics[1].value} />
|
<MoneyValues amount={metrics[1].value} />
|
||||||
</p>
|
</p>
|
||||||
@@ -225,7 +225,7 @@ export function CardItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
<MoneyValues amount={metrics[2].value} />
|
<MoneyValues amount={metrics[2].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-xl font-semibold leading-tight">
|
<h1 className="text-xl font-medium leading-tight">
|
||||||
{category.name}
|
{category.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Total em {currentPeriodLabel}
|
Total em {currentPeriodLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-2xl font-semibold">
|
<p className="mt-1 text-2xl font-medium">
|
||||||
{currencyFormatter.format(currentTotal)}
|
{currencyFormatter.format(currentTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-center gap-1 text-xl font-semibold",
|
"mt-1 flex items-center gap-1 text-xl font-medium",
|
||||||
variationColor,
|
variationColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog
|
|||||||
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||||
import {
|
import {
|
||||||
buildFinancialStatusLabel,
|
buildFinancialStatusLabel,
|
||||||
|
buildRelativeFinancialStatusLabel,
|
||||||
formatFinancialDateLabel,
|
formatFinancialDateLabel,
|
||||||
} from "@/shared/utils/financial-dates";
|
} from "@/shared/utils/financial-dates";
|
||||||
|
|
||||||
@@ -24,6 +25,14 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
|
||||||
|
return buildRelativeFinancialStatusLabel({
|
||||||
|
isSettled: bill.isSettled,
|
||||||
|
dueDate: bill.dueDate,
|
||||||
|
paidAt: bill.boletoPaymentDate,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getCurrentBillDateString = () => getBusinessDateString();
|
export const getCurrentBillDateString = () => getBusinessDateString();
|
||||||
|
|
||||||
export const isBillOverdue = (bill: DashboardBill) => {
|
export const isBillOverdue = (bill: DashboardBill) => {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { transactions } from "@/db/schema";
|
import { transactions } from "@/db/schema";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { toDateOnlyString } from "@/shared/utils/date";
|
import {
|
||||||
|
compareDateOnly,
|
||||||
|
getBusinessDateString,
|
||||||
|
isDateOnlyPast,
|
||||||
|
toDateOnlyString,
|
||||||
|
} from "@/shared/utils/date";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
@@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = {
|
|||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const compareDateOnlyAscWithNullsLast = (
|
||||||
|
left: string | null,
|
||||||
|
right: string | null,
|
||||||
|
) => {
|
||||||
|
if (!left && !right) return 0;
|
||||||
|
if (!left) return 1;
|
||||||
|
if (!right) return -1;
|
||||||
|
return compareDateOnly(left, right);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareDateOnlyDescWithNullsLast = (
|
||||||
|
left: string | null,
|
||||||
|
right: string | null,
|
||||||
|
) => {
|
||||||
|
if (!left && !right) return 0;
|
||||||
|
if (!left) return 1;
|
||||||
|
if (!right) return -1;
|
||||||
|
return compareDateOnly(right, left);
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchDashboardBills(
|
export async function fetchDashboardBills(
|
||||||
userId: string,
|
userId: string,
|
||||||
period: string,
|
period: string,
|
||||||
): Promise<DashboardBillsSnapshot> {
|
): Promise<DashboardBillsSnapshot> {
|
||||||
|
const today = getBusinessDateString();
|
||||||
const adminPayerId = await getAdminPayerId(userId);
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
if (!adminPayerId) {
|
if (!adminPayerId) {
|
||||||
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||||
@@ -59,11 +85,6 @@ export async function fetchDashboardBills(
|
|||||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||||
eq(transactions.payerId, adminPayerId),
|
eq(transactions.payerId, adminPayerId),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
.orderBy(
|
|
||||||
asc(transactions.isSettled),
|
|
||||||
asc(transactions.dueDate),
|
|
||||||
asc(transactions.name),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
||||||
@@ -78,6 +99,55 @@ export async function fetchDashboardBills(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bills.sort((a, b) => {
|
||||||
|
if (a.isSettled !== b.isSettled) {
|
||||||
|
return a.isSettled ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!a.isSettled && !b.isSettled) {
|
||||||
|
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
|
||||||
|
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
|
||||||
|
|
||||||
|
if (aIsOverdue !== bIsOverdue) {
|
||||||
|
return aIsOverdue ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate);
|
||||||
|
if (dueDateDiff !== 0) {
|
||||||
|
return dueDateDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountDiff = b.amount - a.amount;
|
||||||
|
if (amountDiff !== 0) {
|
||||||
|
return amountDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.isSettled && b.isSettled) {
|
||||||
|
const paidAtDiff = compareDateOnlyDescWithNullsLast(
|
||||||
|
a.boletoPaymentDate,
|
||||||
|
b.boletoPaymentDate,
|
||||||
|
);
|
||||||
|
if (paidAtDiff !== 0) {
|
||||||
|
return paidAtDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountDiff = b.amount - a.amount;
|
||||||
|
if (amountDiff !== 0) {
|
||||||
|
return amountDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameDiff = a.name.localeCompare(b.name, "pt-BR", {
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
if (nameDiff !== 0) {
|
||||||
|
return nameDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
let totalPendingAmount = 0;
|
let totalPendingAmount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -111,10 +111,11 @@ export function buildCategoryBreakdownData({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
const filtered = categories.filter((c) => c.currentAmount > 0);
|
||||||
|
filtered.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories: filtered,
|
||||||
currentTotal,
|
currentTotal,
|
||||||
previousTotal,
|
previousTotal,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export async function fetchCategoryDetails(
|
|||||||
sanitizedNote,
|
sanitizedNote,
|
||||||
eq(transactions.period, previousPeriod),
|
eq(transactions.period, previousPeriod),
|
||||||
or(
|
or(
|
||||||
|
isNull(transactions.note),
|
||||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { budgets, categories, transactions } from "@/db/schema";
|
import {
|
||||||
|
budgets,
|
||||||
|
categories,
|
||||||
|
financialAccounts,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
buildCategoryBreakdownData,
|
buildCategoryBreakdownData,
|
||||||
type DashboardCategoryBreakdownData,
|
type DashboardCategoryBreakdownData,
|
||||||
@@ -8,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||||
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
|
|||||||
eq(transactions.transactionType, "Despesa"),
|
eq(transactions.transactionType, "Despesa"),
|
||||||
eq(categories.type, "despesa"),
|
eq(categories.type, "despesa"),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
|
|||||||
eq(categories.type, "receita"),
|
eq(categories.type, "receita"),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
excludeInitialBalanceWhenConfigured(),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
|
|||||||
and(
|
and(
|
||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||||
inArray(transactions.period, [period, previousPeriod]),
|
inArray(transactions.period, [period, previousPeriod]),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
or(
|
or(
|
||||||
and(
|
and(
|
||||||
eq(transactions.transactionType, "Despesa"),
|
eq(transactions.transactionType, "Despesa"),
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||||
import {
|
import {
|
||||||
buildBillStatusLabel,
|
buildBillStatusLabel,
|
||||||
|
buildBillWidgetStatusLabel,
|
||||||
isBillOverdue,
|
isBillOverdue,
|
||||||
} from "@/features/dashboard/bills-helpers";
|
} from "@/features/dashboard/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
type BillListItemProps = {
|
type BillListItemProps = {
|
||||||
@@ -15,8 +21,13 @@ type BillListItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||||
const statusLabel = buildBillStatusLabel(bill);
|
const statusLabel = buildBillWidgetStatusLabel(bill);
|
||||||
|
const absoluteStatusLabel = buildBillStatusLabel(bill);
|
||||||
const overdue = isBillOverdue(bill);
|
const overdue = isBillOverdue(bill);
|
||||||
|
const statusTooltipLabel =
|
||||||
|
statusLabel && statusLabel !== absoluteStatusLabel
|
||||||
|
? absoluteStatusLabel
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
||||||
@@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{statusLabel ? (
|
{statusLabel ? (
|
||||||
<span
|
statusTooltipLabel ? (
|
||||||
className={cn(
|
<Tooltip>
|
||||||
"rounded-full py-0.5",
|
<TooltipTrigger asChild>
|
||||||
bill.isSettled && "text-success",
|
<span
|
||||||
)}
|
className={cn(
|
||||||
>
|
"cursor-help rounded-full py-0.5",
|
||||||
{statusLabel}
|
bill.isSettled && "text-success",
|
||||||
</span>
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{statusTooltipLabel}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full py-0.5",
|
||||||
|
bill.isSettled && "text-success",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user