67 Commits

Author SHA1 Message Date
Felipe Coutinho
7dd480284e fix: corrige path do schema no Dockerfile para src/db
O diretório db/ foi movido para src/db/ na v2.0.0.
O COPY no stage runner ainda apontava para o caminho antigo,
causando falha no build da action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:37:31 +00:00
Felipe Coutinho
0a7c65ec9e Merge pull request #28 from felipegcoutinho/imgbot
[ImgBot] Optimize images
2026-03-21 12:32:04 -03:00
ImgBotApp
05daac5f57 [ImgBot] Optimize images
*Total -- 806.29kb -> 592.37kb (26.53%)

/public/logos/orama.png -- 32.36kb -> 7.66kb (76.33%)
/public/logos/sofisadireto.png -- 4.95kb -> 1.61kb (67.5%)
/public/logos/vtbbank.png -- 5.12kb -> 1.85kb (63.79%)
/public/images/web-app-manifest-192x192.png -- 5.40kb -> 2.37kb (56.14%)
/public/logos/picpay.png -- 2.38kb -> 1.09kb (54.04%)
/public/logos/toroinvestimentos.png -- 3.45kb -> 1.69kb (50.99%)
/public/logos/z1.png -- 2.03kb -> 1.00kb (50.75%)
/public/images/web-app-manifest-512x512.png -- 18.92kb -> 9.34kb (50.65%)
/public/logos/monetus.png -- 18.34kb -> 9.25kb (49.57%)
/public/logos/cimbbank.png -- 6.67kb -> 3.57kb (46.51%)
/public/logos/agibank.png -- 3.07kb -> 1.65kb (46.19%)
/public/logos/nuconta.png -- 1.79kb -> 0.96kb (46.18%)
/public/logos/ifood-beneficios.png -- 4.13kb -> 2.30kb (44.33%)
/public/logos/woop.png -- 29.46kb -> 16.90kb (42.65%)
/public/logos/banese.png -- 3.29kb -> 1.98kb (40.01%)
/public/avatars/default_icon.png -- 5.41kb -> 3.31kb (38.87%)
/public/logos/tesouronacional.png -- 4.65kb -> 2.88kb (38.14%)
/public/logos/bilhete-unico.png -- 3.62kb -> 2.24kb (38.01%)
/src/app/icon1.png -- 5.41kb -> 3.37kb (37.69%)
/src/app/apple-icon.png -- 5.41kb -> 3.37kb (37.69%)
/public/logos/pluxxe.png -- 1.20kb -> 0.75kb (37.33%)
/public/logos/sodexo.png -- 5.66kb -> 3.75kb (33.8%)
/public/logos/tesourodireto.png -- 4.17kb -> 2.78kb (33.34%)
/public/logos/viacredi.png -- 7.36kb -> 5.00kb (32.11%)
/public/logos/brde.png -- 5.48kb -> 3.93kb (28.16%)
/public/logos/spuerkeess.png -- 2.63kb -> 1.92kb (26.89%)
/public/logos/mais.png -- 8.43kb -> 6.23kb (26.11%)
/public/logos/efi.bank.png -- 2.73kb -> 2.03kb (25.72%)
/public/avatars/4825062.png -- 25.78kb -> 19.30kb (25.13%)
/public/logos/banrisul.png -- 4.15kb -> 3.14kb (24.31%)
/public/avatars/4825021.png -- 21.18kb -> 16.20kb (23.51%)
/public/avatars/4825096.png -- 27.46kb -> 21.06kb (23.32%)
/public/avatars/4825057.png -- 22.96kb -> 17.63kb (23.21%)
/public/avatars/4825076.png -- 26.29kb -> 20.21kb (23.14%)
/public/avatars/4825044.png -- 21.44kb -> 16.60kb (22.59%)
/public/avatars/4825066.png -- 21.82kb -> 16.90kb (22.53%)
/public/avatars/4825038.png -- 25.82kb -> 20.00kb (22.52%)
/public/avatars/4825123.png -- 21.71kb -> 16.83kb (22.48%)
/public/avatars/4825031.png -- 25.69kb -> 19.93kb (22.4%)
/public/avatars/4825072.png -- 28.61kb -> 22.30kb (22.06%)
/public/avatars/4825082.png -- 23.32kb -> 18.21kb (21.91%)
/public/avatars/4825015.png -- 23.36kb -> 18.26kb (21.84%)
/public/avatars/4825108.png -- 20.25kb -> 15.89kb (21.56%)
/public/avatars/4825051.png -- 26.30kb -> 20.79kb (20.95%)
/public/avatars/4825027.png -- 22.74kb -> 17.98kb (20.93%)
/public/avatars/4825087.png -- 21.15kb -> 16.74kb (20.85%)
/public/avatars/4825112.png -- 25.28kb -> 20.04kb (20.72%)
/public/logos/btgpactual.png -- 3.51kb -> 2.85kb (18.62%)
/public/images/logo_text.png -- 26.08kb -> 21.34kb (18.18%)
/public/logos/crefisa.png -- 4.44kb -> 3.64kb (17.98%)
/public/avatars/4825035.png -- 25.01kb -> 20.69kb (17.25%)
/public/images/logo_small.png -- 3.79kb -> 3.16kb (16.79%)
/public/logos/wiipo.png -- 9.57kb -> 7.98kb (16.6%)
/public/avatars/4825047.png -- 20.26kb -> 17.29kb (14.69%)
/public/logos/nubank-ultravioleta.png -- 10.00kb -> 8.93kb (10.68%)
/public/logos/riachuelo.png -- 1.09kb -> 0.99kb (9.55%)
/public/logos/clear-corretora.png -- 4.13kb -> 3.76kb (8.97%)
/public/logos/bradesco-empresas.png -- 3.39kb -> 3.15kb (7.09%)
/public/icons/party.svg -- 4.15kb -> 3.98kb (4.02%)
/public/logos/pix.png -- 1.57kb -> 1.51kb (3.79%)
/public/providers/gemini.svg -- 0.87kb -> 0.86kb (1.46%)
/public/providers/openrouter_light.svg -- 0.56kb -> 0.55kb (1.05%)
/public/logos/creditas.png -- 11.04kb -> 10.94kb (0.95%)
/public/flags/visa.svg -- 1.37kb -> 1.36kb (0.93%)
/public/flags/mastercard.svg -- 5.13kb -> 5.12kb (0.3%)
/public/providers/chatgpt.svg -- 2.61kb -> 2.61kb (0.15%)
/public/providers/chatgpt_dark_mode.svg -- 2.61kb -> 2.61kb (0.15%)
/public/flags/elo.svg -- 1.66kb -> 1.66kb (0.12%)
/public/providers/claude.svg -- 2.51kb -> 2.50kb (0.12%)
/public/flags/amex.svg -- 4.81kb -> 4.81kb (0.04%)
/public/flags/hipercard.svg -- 17.29kb -> 17.29kb (0.01%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-03-21 15:28:30 +00:00
Felipe Coutinho
a893473388 Merge pull request #27 from felipegcoutinho/release/v2.0.0
release: v2.0.0
2026-03-21 12:23:55 -03:00
Felipe Coutinho
3d850be60f feat(landing): adiciona aba de importação na seção de telas
Inclui aba "Importação" após "Pré-lançamentos" no carrossel de
screenshots, com assets WebP (light/dark) e ícone RiFileDownloadLine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:14:56 +00:00
Felipe Coutinho
dd8fd61c32 refactor: simplifica backup.sh — pg_restore, log dinâmico e rclone escopado
- Substitui terceiro pg_dump (data-only) por pg_restore sobre o .dump já
  criado, eliminando uma conexão extra ao banco em ambos os modos
- Move timestamp para dentro de log() para refletir o horário real de
  cada mensagem, não apenas o instante de início do script
- Escopa rclone copy por TIMESTAMP para não re-enviar backups anteriores
  a cada execução

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:06:36 +00:00
Felipe Coutinho
eef80b4daa feat: adiciona export data-only (*.data.sql.gz) ao backup
Gera um dump adicional com dados puros de todas as tabelas públicas
(--data-only --schema=public) para ambos os modos remote e docker.
Remove --min-age do rclone para garantir upload imediato do arquivo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:28:35 +00:00
Felipe Coutinho
6d891d3b29 docs: atualiza README para a v2.0.0
- Nuança o aviso de "sem Open Finance" mencionando importação OFX/XLS
- Atualiza linha de funcionalidades de transações
- Adiciona pnpm backup na seção de scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:12:46 +00:00
Felipe Coutinho
4b6f791265 docs: simplifica descrição do backup.sh no changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:10:56 +00:00
Felipe Coutinho
655fc64977 docs: corrige e completa changelog da v2.0.0
- Corrige formatação quebrada do item de importação (commit message colado cru)
- Enxuga o item do script de backup
- Adiciona os 3 fixes do dia (category-trends, dashboard cards, landing page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:09:51 +00:00
Felipe Coutinho
56a23c40cf fix: corrige ícones e cor nos cards de métricas do dashboard
Troca as setas de receita/despesa (estavam invertidas) e ajusta a
cor do card de saldo para cyan-600.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:05:39 +00:00
Felipe Coutinho
9377e451de fix: remove gradiente sobreposto da hero da landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:04:47 +00:00
Felipe Coutinho
bdb3908dab fix: média em category-trends ignora meses sem gastos
Corrige o cálculo da coluna Média para dividir apenas pelo número de
meses com valores > 0, evitando distorção causada por meses sem
movimentação. Adiciona ícone de informação com tooltip explicativo
no cabeçalho da coluna.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:04:34 +00:00
Felipe Coutinho
a20fe255f3 feat: importação de extratos OFX/XLS com memória de categorias
Adiciona fluxo completo de importação de extratos bancários:
- Upload e parsing de arquivos OFX e XLS/XLSX
- Tela de revisão com virtualização (@tanstack/react-virtual)
- Detecção automática de categoria por histórico de uso
- Deduplicação por FITID (OFX) e importBatchId
- Tabela `import_category_mappings` para persistir mapeamentos
- Botão de acesso ao fluxo na tabela de transações

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:04:30 +00:00
Felipe Coutinho
deb7c775f8 docs: atualiza changelog da versão 2.0.0 2026-03-20 18:43:22 +00:00
Felipe Coutinho
3e0ce15258 chore: atualiza setup, backup e toolchain 2026-03-20 18:43:03 +00:00
Felipe Coutinho
e4dd221709 feat: endurece mutações financeiras e permite zerar conta 2026-03-20 18:42:18 +00:00
Felipe Coutinho
f77c64325d refactor: modulariza insights e atualiza catálogo de IA 2026-03-20 18:41:34 +00:00
Felipe Coutinho
29551ee02f feat: pagina inbox e valida tokens do companion 2026-03-20 18:40:13 +00:00
Felipe Coutinho
3c31ee5d90 refactor: pagina transações e modulariza ações 2026-03-20 18:39:49 +00:00
Felipe Coutinho
41fd8226cb refactor: agrega queries e cache do dashboard 2026-03-20 18:38:20 +00:00
Felipe Coutinho
5b8d25d894 feat: reformula landing page e experiência mobile 2026-03-20 18:35:12 +00:00
Felipe Coutinho
33a5d6f5f0 feat(landing): reformula página inicial e atualiza previews 2026-03-17 17:11:19 +00:00
Felipe Coutinho
58f5a4ab2f style(ui): reordena exports e padroniza rótulos visuais 2026-03-17 17:11:05 +00:00
Felipe Coutinho
a7c6f3c632 refactor(anotações): centraliza transformação dos dados de notas 2026-03-17 17:10:06 +00:00
Felipe Coutinho
076953340f fix(pagadores): corrige envio e seleção de avatar no diálogo 2026-03-17 17:09:59 +00:00
Felipe Coutinho
50177621ff feat(dashboard): refina layout e widgets do painel 2026-03-17 17:09:40 +00:00
Felipe Coutinho
272e90aef9 feat(ui): padroniza avatares e paleta visual da interface 2026-03-17 17:08:54 +00:00
Felipe Coutinho
7064c0b0bc feat(categorias): adiciona seletor pesquisável de ícones 2026-03-17 17:08:11 +00:00
Felipe Coutinho
ff016113b9 chore(db): adiciona índice composto para filtros de lançamentos 2026-03-17 17:07:48 +00:00
Felipe Coutinho
36687debf2 fix(dashboard): usa pagador admin cacheado nas consultas 2026-03-17 17:07:34 +00:00
Felipe Coutinho
fd702276d8 chore(deps): atualiza dependências e lockfile 2026-03-17 17:07:19 +00:00
Felipe Coutinho
39711615ee fix(metadata): corrige duplicação dos títulos das páginas 2026-03-17 17:04:50 +00:00
Felipe Coutinho
2cb5033486 fix: corrige tipagem compartilhada e compatibilidade do typecheck 2026-03-16 01:24:04 +00:00
Felipe Coutinho
132f98c0f8 refactor: compartilha utilitários e refina widgets e calendário 2026-03-16 01:14:55 +00:00
Felipe Coutinho
959db963b8 feat: amplia ações e seleção em lote no inbox 2026-03-16 01:14:47 +00:00
Felipe Coutinho
f4e7108119 feat: melhora os dialogs e detalhes de lançamentos 2026-03-16 01:14:40 +00:00
Felipe Coutinho
69df314db7 feat: aprimora a edição e visualização de anotações 2026-03-16 01:14:33 +00:00
Felipe Coutinho
fc86b9002e docs: atualiza o changelog da versão 2.0.0 2026-03-15 23:24:21 +00:00
Felipe Coutinho
a4da0a7143 feat: move o changelog para uma rota dedicada 2026-03-15 23:24:10 +00:00
Felipe Coutinho
173fc86920 feat: adiciona ações em lote ao inbox 2026-03-15 23:24:00 +00:00
Felipe Coutinho
1823b6be56 style: atualiza loadings e skeletons do dashboard 2026-03-15 23:23:53 +00:00
Felipe Coutinho
e84becd1cd feat: aprimora o fluxo de pagamento de faturas e boletos 2026-03-15 23:23:42 +00:00
Felipe Coutinho
ca67d36f33 style: redesenha cards-resumo de conta e fatura 2026-03-15 23:23:35 +00:00
Felipe Coutinho
df3d0134be feat: melhora a UX de lançamentos e ações rápidas 2026-03-15 23:23:26 +00:00
Felipe Coutinho
2712d4919a style: padroniza widgets e listas do dashboard 2026-03-15 23:23:12 +00:00
Felipe Coutinho
64eb29d807 style: refina base visual e navegação 2026-03-15 23:23:00 +00:00
Felipe Coutinho
5a78fd614c Atualiza changelog das mudancas pendentes 2026-03-14 18:36:24 +00:00
Felipe Coutinho
62b94e6b1d Padroniza copias e badges da interface 2026-03-14 18:36:02 +00:00
Felipe Coutinho
1e8e6e0d3d Refina tema global e experiencia visual de auth 2026-03-14 18:35:39 +00:00
Felipe Coutinho
9fb3cc5ecd Remove infraestrutura de series recorrentes 2026-03-14 18:35:28 +00:00
Felipe Coutinho
a143f70269 docs: registra migracao de identificadores 2026-03-14 12:51:48 +00:00
Felipe Coutinho
2f60ee6639 chore: simplifica pesos da fonte america 2026-03-14 12:51:43 +00:00
Felipe Coutinho
2d5375b2cc fix: atualiza protecao das rotas do dashboard 2026-03-14 12:51:33 +00:00
Felipe Coutinho
6854017a8c refactor: atualiza transacoes dashboard e relatorios 2026-03-14 12:51:22 +00:00
Felipe Coutinho
43b0f0c47e refactor: traduz dominio de payers no app 2026-03-14 12:51:08 +00:00
Felipe Coutinho
67ad4b9d02 refactor: alinha features financeiras ao novo naming 2026-03-14 12:50:55 +00:00
Felipe Coutinho
ef918a3667 refactor: traduz contratos compartilhados do schema 2026-03-14 12:50:43 +00:00
Felipe Coutinho
fa9bf17663 Simplifica tipografia para fonte America 2026-03-13 18:21:01 +00:00
Felipe Coutinho
20c14aa96f fix(finance): aceita anotacao nula e usa valor liquido na fatura 2026-03-12 19:23:13 +00:00
Felipe Coutinho
b0fbb1062a refactor(core): move app para src e padroniza estrutura 2026-03-12 19:22:50 +00:00
Felipe Coutinho
d92e70f1b9 chore(release): remove legados e fecha versao 2.0.0 2026-03-09 17:15:04 +00:00
Felipe Coutinho
f724d8ac04 feat(branding): atualiza landing, fontes e assets publicos 2026-03-09 17:14:37 +00:00
Felipe Coutinho
6205dee42a feat(reports): melhora notas, calendario e analises 2026-03-09 17:14:04 +00:00
Felipe Coutinho
ada1377640 feat(finance): refina fluxos de transacoes e pagadores 2026-03-09 17:13:44 +00:00
Felipe Coutinho
69da27276c refactor(dashboard): reorganiza widgets e remove magnet-lines 2026-03-09 17:12:44 +00:00
Felipe Coutinho
3e06a1d056 refactor(core): centraliza hooks, providers e base compartilhada 2026-03-09 17:11:55 +00:00
811 changed files with 46142 additions and 25516 deletions

7
.gitignore vendored
View File

@@ -104,11 +104,10 @@ docker-compose.override.yml
.claude/
.gemini/
.cursor/
CLAUDE.md
AGENTS.md
QWEN.md
claude.md
agents.md
AGENTS.md
# === Backups locais ===
/backup/
# === Backups e Temporários ===
*.bak

1
.nvmrc
View File

@@ -1 +0,0 @@
v22.12.0

View File

@@ -27,5 +27,9 @@
},
"eslint.enable": false,
"prettier.enable": false,
"typescript.preferences.organizeImportsCollation": "ordinal"
"typescript.preferences.organizeImportsCollation": "ordinal",
"editor.fontSize": 15,
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

View File

@@ -5,7 +5,61 @@ 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/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [Unreleased]
## [2.0.0] - 2026-03-21
### Adicionado
- Infraestrutura: script `scripts/backup.sh` para backup automático do banco PostgreSQL; configuração de destino (rclone, cron, retenção) feita separadamente; passa a gerar também `*.data.sql.gz` com dados puros de todas as tabelas públicas (`--data-only --schema=public`)
- Importação de extratos OFX e XLS/XLSX com tela de revisão, detecção automática de categoria por histórico de uso, deduplicação por FITID e acesso direto pela tabela de transações
### Alterado
- Ajustes: aba de exclusão da conta passa a oferecer opção de zerar dados financeiros (preferências, tokens do Companion, compartilhamentos) sem excluir o usuário; categorias e pagador admin são recriados em seguida.
- Performance: paginação server-side real com `count`, `limit` e `offset` em transações, extrato e inbox, com sincronização de `page`, `pageSize` e `status` na URL; `fetchInboxDialogData()` restrito ao fluxo de processamento.
- Performance: dashboard reduzido de 19 fetchers para 7 blocos com agregações compartilhadas; snapshots dedicados para navbar (avatar do pagador admin, notificações, inbox) e quick actions, ambos com cache por usuário.
- Performance: exportações de lançamentos e relatório por categoria carregam `xlsx`, `jspdf` e `jspdf-autotable` sob demanda, apenas no clique.
- Performance: agregação de insights busca o pagador admin uma vez por request, remove joins repetidos com `pagadores` e paraleliza consultas independentes do período.
- Cache: invalidação do dashboard segmentada por `userId` nas server actions; `revalidateForEntity()` agora exige `userId`, sem fallback global para dashboard.
- Cache: agregação de insights com cache por usuário e período, reaproveitando a invalidação financeira segmentada.
- Arquitetura: `getAdminPayerId` adotado em contas, orçamentos, calendário, detalhe de categoria, extrato e actions, eliminando JOINs repetidos com `payers.role`.
- Banco: unique constraints compostas em `faturas` e `orcamentos`, com migration que aborta em caso de duplicatas históricas; actions tratam conflitos de concorrência com `upsert` para status de fatura e `onConflictDoNothing` para orçamentos.
- Qualidade: `pnpm run lint` e `next build` passam sem erros de TypeScript; validação de tipos ativa no build.
- Refatoração: identificadores internos migrados de PT-BR para inglês (`lancamento``transaction`, `pagador``payer`, `conta``account`, `cartao``card`, `categoria``category`, `orcamento``budget`); strings de UI permanecem em português. Search params de lançamentos também migrados (`type`, `condition`, `payment`, `payer`, `category`, `accountCard`).
- Lançamentos recorrentes: criação de todos os meses diretamente no fluxo do lançamento, com seleção explícita da quantidade de meses no formulário.
- UI: `type-badge` renomeado para `transaction-type-badge` com mapeamento centralizado por tipo financeiro; visual unificado em tabela, detalhe de transação e cabeçalho de categoria.
- UI: navbar com `dot pattern` SVG sutil sobre a cor primária, máscara horizontal e camada de luz suave; cards de login/cadastro reaproveitam a mesma linguagem visual com `dot pattern` e brilho em `primary`.
- UI: login e cadastro reequilibrados com espaçamentos mais consistentes, largura útil fixa e cabeçalhos com descrição.
- UI: labels padronizados em formulários, tabelas, relatórios e estados vazios; skeletons com cantos menos arredondados; loading da home espelha estrutura atual (boas-vindas, navegação mensal, cards de métricas e toolbar de widgets).
- Faturas: card de resumo refinado com hierarquia clara para valor, vencimento e status; metadados em blocos discretos e faixa de ação contextual para pagamento e edição de data.
- Tipografia: aplicação carrega apenas a família `America` (`regular`, `medium` e `bold`) como fonte global, removendo personalização por usuário e distinção de fonte para valores monetários.
- Pagadores: a tela de detalhe agora mantém o card principal do pagador visível durante a navegação entre abas, sem repetir o bloco completo dentro de cada seção.
- Pagadores: detalhes sensíveis como envio automático, último envio e observações agora ficam ocultos quando o acesso ao pagador é somente leitura.
- Pagadores: o e-mail do pagador agora aparece apenas no cabeçalho fixo, evitando repetição dentro do card de detalhes.
- Relatório de tendências: a tabela e os cards mobile agora exibem a média mensal do período filtrado ao lado do total, com destaque visual em azul; a coluna de categoria também ficou mais compacta com truncamento para nomes longos.
- Dashboard: o welcome banner deixou de ser um bloco colorido para virar apenas texto destacado.
- UI base: o `Card` compartilhado agora mantém a borda neutra no estado padrão e aplica um gradiente entre `border` e `primary` no hover.
- Assets: imagens que estavam soltas na raiz de `public/` foram movidas para `public/imagens/`, com atualização dos caminhos usados por landing page, logos, exports e manifesto do app.
- Dashboard: `section-cards` foi renomeado para `dashboard-metrics-cards`; `boletos-widget` renomeado para `bill-widget`; widgets componentizados internamente por domínio (`invoices/`, `bills/`, `notes/`, `goals-progress/`, `payment-overview/`, `installment-expenses/`).
- Widgets: `widget-card` foi separado entre um card base e uma versão expansível, isolando a lógica de overflow sem alterar o visual atual dos widgets.
- Datas: helpers de `YYYY-MM-DD`, labels de vencimento/pagamento e o relógio de negócio foram centralizados em `lib/utils/date.ts`, reduzindo drift de timezone em dashboard, pagadores, calendário, exports e actions.
- Lançamentos: a tabela deixou de quebrar ao formatar datas inválidas ou serializadas como ISO completo, normalizando `purchaseDate` para `YYYY-MM-DD` com fallback seguro.
- Logos e cartões: resolução de logos e brand assets foi consolidada em `lib/logo/index.ts` e `lib/cartoes/brand-assets.ts`, com adoção em cartões, contas, notificações, inbox, relatórios e seletores.
### Corrigido
- Relatório de tendências: a coluna Média agora considera apenas os meses com gastos registrados (valores > 0), ignorando meses sem movimentação no cálculo
- Dashboard: ícones de seta nos cards de métricas (receita/despesa) estavam invertidos; cor do card de saldo ajustada para `cyan-600`
- Landing page: gradiente sobreposto removido da hero section
- Lançamentos: o schema compartilhado de observação voltou a aceitar `null`, corrigindo o erro `Invalid input: expected string, received null` ao salvar novos lançamentos sem anotação.
- Cartões/Faturas: o pagamento da fatura passou a usar o valor líquido do período no cartão, evitando que o extrato da conta registre o total bruto das despesas quando houver receitas como estornos ou créditos na mesma fatura.
- Hooks e sincronização: o provider de privacidade voltou a reagir corretamente às mudanças do modo privado, e o resumo de fatura agora reseta a data de pagamento quando a prop inicial deixa de existir.
- Compatibilidade da refatoração de hooks e relatórios: `useMobile`/`useIsMobile` voltaram a ter exports compatíveis, o shim de `components/ui/use-mobile.ts` foi restaurado para o sidebar e `lib/relatorios/types.ts` voltou a reexportar os tipos usados pelos fetchers legados.
- Widgets expansíveis: o shell compartilhado voltou a aplicar `relative` e `overflow-hidden`, mantendo o gradiente e o botão "Ver tudo" presos ao card.
- Dashboard: o widget "Lançamentos por categoria" deixou de ler a categoria salva no `sessionStorage` durante a renderização inicial, evitando mismatch de hidratação entre servidor e cliente.
### Removido
- Dashboard/Ajustes: toda a implementação legada de `magnet-lines` foi removida, incluindo componente órfão, preferência de usuário e a coluna `disable_magnetlines` do schema com migration dedicada.
## [1.7.7] - 2026-03-05

318
CLAUDE.md Normal file
View File

@@ -0,0 +1,318 @@
# CLAUDE.md - OpenMonetis
> Self-hosted personal finance app (Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind 4, shadcn/ui).
> Portuguese UI, English folders/imports. Linter: Biome 2.x. Package manager: pnpm.
## Related Projects
- **OpenMonetis Companion** (`~/github/openmonetis-companion`): Android app que captura notificacoes de apps bancarios e envia para o OpenMonetis via API. Os itens chegam na feature `inbox` para revisao.
---
## Critical Rules
1. **Sempre filtrar por `userId`** em queries.
2. **Usar `getAdminPayerId(userId)`** de `src/shared/lib/payers/get-admin-id.ts` ao inves de JOIN com `payers` para descobrir o admin.
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`.
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.
7. **Comunicacao**: responder em portugues clara e direta com o time.
---
## Architecture
### Feature-First
- `src/app/`: roteamento, layouts, loading states e paginas finas
- `src/features/`: codigo de dominio por feature
- `src/shared/`: tudo que e genuinamente reutilizado entre features
- `src/db/`: schema do banco
### Regra Feature vs Shared
Use esta pergunta:
> Se eu deletar esta feature, este arquivo deveria sumir junto?
- Sim: vai para `src/features/<feature>/`
- Nao: vai para `src/shared/`
### Features nao importam outras features
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
Exemplos comuns:
- auth: `src/shared/lib/auth/*`
- db: `src/shared/lib/db.ts`
- revalidation helpers: `src/shared/lib/actions/*`
- payers cross-domain helpers: `src/shared/lib/payers/*`
- period/currency/date: `src/shared/utils/*`
- shadcn/ui: `src/shared/components/ui/*`
---
## Directory Structure
```text
src/
├── app/
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── signup/page.tsx
│ ├── (dashboard)/
│ │ ├── dashboard/
│ │ ├── transactions/
│ │ ├── cards/
│ │ │ └── [cardId]/invoice/
│ │ ├── accounts/
│ │ │ └── [accountId]/statement/
│ │ ├── categories/
│ │ │ ├── [categoryId]/
│ │ │ └── history/
│ │ ├── budgets/
│ │ ├── payers/
│ │ │ └── [payerId]/
│ │ ├── notes/
│ │ ├── insights/
│ │ ├── calendar/
│ │ ├── inbox/
│ │ ├── changelog/
│ │ ├── reports/
│ │ │ ├── category-trends/
│ │ │ ├── card-usage/
│ │ │ ├── installment-analysis/
│ │ │ └── establishments/
│ │ └── settings/
│ ├── (landing-page)/
│ ├── api/
│ ├── globals.css
│ └── layout.tsx
├── features/
│ ├── auth/
│ ├── landing/
│ ├── dashboard/
│ ├── transactions/
│ ├── cards/
│ ├── invoices/
│ ├── accounts/
│ ├── categories/
│ ├── budgets/
│ ├── payers/
│ ├── notes/
│ ├── insights/
│ ├── calendar/
│ ├── inbox/
│ ├── reports/
│ └── settings/
├── shared/
│ ├── components/
│ │ ├── ui/
│ │ ├── navigation/
│ │ ├── providers/
│ │ ├── month-picker/
│ │ ├── logo-picker/
│ │ ├── calculator/
│ │ ├── entity-avatar/
│ │ └── skeletons/
│ ├── hooks/
│ ├── lib/
│ │ ├── actions/
│ │ ├── auth/
│ │ ├── accounts/
│ │ ├── cards/
│ │ ├── calculator/
│ │ ├── categories/
│ │ ├── email/
│ │ ├── installments/
│ │ ├── invoices/
│ │ ├── logo/
│ │ ├── payers/
│ │ ├── schemas/
│ │ ├── transfers/
│ │ ├── types/
│ │ └── db.ts
│ └── utils/
│ ├── period/
│ ├── currency.ts
│ ├── date.ts
│ ├── financial-dates.ts
│ ├── percentage.ts
│ ├── category-colors.ts
│ ├── calendar.ts
│ ├── math.ts
│ ├── number.ts
│ ├── string.ts
│ ├── initials.ts
│ ├── icons.tsx
│ ├── export-branding.ts
│ ├── ui.ts
│ └── calculator.ts
└── db/
└── schema.ts
```
---
## Import Patterns
### Preferidos
```ts
import { getUser } from "@/shared/lib/auth/server";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { parsePeriodParam } from "@/shared/utils/period";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { fetchLancamentos } from "@/features/transactions/queries";
```
### Evitar
```ts
import { Something } from "@/components/...";
import { Something } from "@/lib/...";
import { something } from "@/app/(dashboard)/...";
```
---
## App Router Pattern
Paginas em `src/app/` devem ser finas:
```ts
import { getUser } from "@/shared/lib/auth/server";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { fetchLancamentos } from "@/features/transactions/queries";
export default async function Page() {
const user = await getUser();
const data = await fetchLancamentos([/* filters */]);
return <TransactionsPage {...data} />;
}
```
Layouts, `loading.tsx` e metadata continuam em `src/app/`.
---
## Naming
### Routes / folders
| Portugues | English |
|---|---|
| `lancamentos` | `transactions` |
| `cartoes` | `cards` |
| `contas` | `accounts` |
| `categorias` | `categories` |
| `orcamentos` | `budgets` |
| `pagadores` | `payers` |
| `anotacoes` | `notes` |
| `calendario` | `calendar` |
| `ajustes` | `settings` |
| `pre-lancamentos` | `inbox` |
| `relatorios/tendencias` | `reports/category-trends` |
| `relatorios/uso-cartoes` | `reports/card-usage` |
| `relatorios/analise-parcelas` | `reports/installment-analysis` |
| `relatorios/estabelecimentos` | `reports/establishments` |
| `contas/[contaId]/extrato` | `accounts/[accountId]/statement` |
| `cartoes/[cartaoId]/fatura` | `cards/[cardId]/invoice` |
| `categorias/historico` | `categories/history` |
| `changelog` | `settings/changelog` |
### Files
- preferir `kebab-case`
- preferir nomes em ingles
- manter nomes internos de tipos/funcoes somente quando a troca aumentar risco sem ganho real
---
## Commands
```bash
pnpm run dev
pnpm run build
pnpm run lint
pnpm run lint:fix
pnpm exec next typegen
pnpm exec tsc --noEmit
pnpm run db:generate
pnpm run db:push
pnpm run db:studio
pnpm run docker:up:db
```
---
## Revalidation
Arquivo: `src/shared/lib/actions/helpers.ts`
- atualizar sempre os paths em ingles
- lembrar de manter a tag `"dashboard"` para invalidacoes financeiras
---
## Auth
- `getUser()` / `getUserId()` em `src/shared/lib/auth/server.ts`
- sessao deduplicada por request com `React.cache()`
---
## Dashboard Fetcher
Padrao recomendado:
```ts
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export async function fetchData(userId: string, period: string) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
return db.query.transactions.findMany({
where: /* sempre com userId + adminPayerId + period */,
});
}
```
---
## New Feature Checklist
1. Criar a rota fina em `src/app/(dashboard)/<feature>/page.tsx`
2. Criar a feature em `src/features/<feature>/`
3. Separar:
- `components/`
- `queries.ts`
- `actions.ts`
- `types.ts` ou `schemas.ts` quando fizer sentido
4. Extrair para `src/shared/` tudo que for reutilizavel
5. Atualizar navegacao e `revalidateForEntity()` se a feature tiver CRUD
6. Rodar:
- `pnpm exec next typegen`
- `pnpm exec tsc --noEmit`
- `pnpm run lint`
---
## Response Style
Quando o time pedir avaliacao de plano ou feature:
1. Responder em portugues simples.
2. Listar 3-5 problemas principais.
3. Fechar com decisao pratica:
- aprova agora
- nao aprova agora
- o que ajustar antes de comecar codigo
Exemplo:
- "Nao aprovaria para comecar codigo imediatamente."
- "Primeiro ajustaria o doc com estes 5 pontos."

View File

@@ -67,7 +67,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copiar arquivos do Drizzle (migrations e schema)
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/db ./db
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
# Copiar node_modules para ter drizzle-kit disponível para migrations
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules

116
README.md
View File

@@ -1,15 +1,14 @@
<p align="center">
<img src="./public/logo_small.png" alt="OpenMonetis Logo" height="80" />
<img src="./public/images/logo_small.png" alt="OpenMonetis Logo" height="80" />
</p>
<p align="center">
Projeto pessoal de gestão financeira. Self-hosted, manual e open source.
</p>
> **📢 Este projeto foi renomeado de OpenSheets para OpenMonetis.** Se você conhecia o projeto pelo nome anterior, é o mesmo — só mudou o nome!
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.0.0-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -21,7 +20,7 @@
---
<p align="center">
<img src="./public/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
</p>
---
@@ -29,7 +28,8 @@
## 📖 Índice
- [Sobre o Projeto](#-sobre-o-projeto)
- [Início Rápido](#-início-rápido)
- [Instalação via Script](#-instalação-via-script)
- [Início Rápido (manual)](#-início-rápido)
- [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker)
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
@@ -52,15 +52,15 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
**1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor.
**2. Não há Open Finance**Você precisa registrar manualmente suas transações.
**2. Não há Open Finance**Não há conexão automática com bancos. Você pode registrar transações manualmente ou importar extratos nos formatos OFX e XLS/XLSX.
**3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo.
### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, extratos detalhados e importação em massa.
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
📊 **Dashboard e relatórios**20+ widgets interativos, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel.
📊 **Dashboard e relatórios**Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel.
💳 **Faturas de cartão** — Acompanhe faturas por período, controle limites e vencimentos.
@@ -78,13 +78,13 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia como pré-lançamentos para revisão. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
⚙️ **Personalização** — Tema dark/light, modo privacidade, fontes customizáveis, preferências por usuário.
⚙️ **Personalização** — Tema dark/light e modo privacidade.
### Stack técnica
- **Next.js** (App Router, Turbopack) + **React** + **TypeScript**
- **PostgreSQL** + **Drizzle ORM**
- **Better Auth** (email/senha + OAuth)
- **Better Auth** (email/senha, OAuth, Passkeys/WebAuthn)
- **shadcn/ui** (Radix UI) + **Tailwind CSS**
- **Docker** (multi-stage build)
- **Biome** (linting + formatting)
@@ -92,7 +92,30 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
---
## 🚀 Início Rápido
## Instalação via Script
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
**Pré-requisito:** Node.js 22+
```bash
# Mac / Linux / WSL
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs
# Windows (PowerShell)
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
```
O script irá:
- Verificar Node, pnpm, Git e Docker
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
- Gerar o `BETTER_AUTH_SECRET` automaticamente
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
- Clonar o repositório, instalar dependências e aplicar o schema
---
## 🚀 Início Rápido (manual)
### Pré-requisitos
@@ -169,6 +192,12 @@ pnpm db:push # Push schema direto (dev)
pnpm db:studio # Drizzle Studio (UI visual)
```
### Utilitários
```bash
pnpm backup # Backup do banco (requer scripts/backup.sh configurado)
```
### Docker
```bash
@@ -251,32 +280,49 @@ OPENROUTER_API_KEY=
## 🏗️ Arquitetura
O projeto segue arquitetura **feature-first** dentro de `src/`:
```
openmonetis/
├── app/ # Next.js App Router
│ ├── api/ # API Routes (auth, health, inbox)
├── (auth)/ # Login e cadastro
│ ├── (dashboard)/ # Rotas protegidas
└── (landing-page)/ # Página inicial pública
├── src/
│ ├── app/ # Next.js App Router (rotas finas)
│ ├── api/ # API Routes (auth, health, inbox)
├── (auth)/ # Login e cadastro
│ ├── (dashboard)/ # Rotas protegidas (transactions, cards, accounts, etc.)
│ │ └── (landing-page)/ # Página inicial pública
│ │
│ ├── features/ # Código de domínio por feature
│ │ ├── dashboard/ # Widgets, queries e métricas
│ │ ├── transactions/ # Lançamentos, ações em lote, exportação
│ │ ├── cards/ # Cartões de crédito
│ │ ├── invoices/ # Faturas
│ │ ├── accounts/ # Contas bancárias
│ │ ├── categories/ # Categorias e histórico
│ │ ├── budgets/ # Orçamentos
│ │ ├── payers/ # Pagadores e compartilhamento
│ │ ├── inbox/ # Pré-lançamentos do Companion
│ │ ├── insights/ # Análises com IA
│ │ ├── reports/ # Relatórios e exportações
│ │ ├── notes/ # Anotações
│ │ ├── calendar/ # Calendário financeiro
│ │ ├── settings/ # Ajustes do usuário
│ │ ├── landing/ # Landing page
│ │ └── auth/ # Formulários de autenticação
│ │
│ ├── shared/ # Código reutilizado entre features
│ │ ├── components/ # UI compartilhada (shadcn/ui, navigation, skeletons...)
│ │ ├── hooks/ # React hooks globais
│ │ ├── lib/ # Helpers de domínio (auth, db, payers, schemas, email...)
│ │ └── utils/ # Utilitários (currency, date, period, math, string...)
│ │
│ └── db/
│ └── schema.ts # Drizzle schema (fonte única de verdade)
├── components/ # React Components (~200 arquivos)
│ ├── ui/ # shadcn/ui (40+ componentes)
│ ├── dashboard/ # Widgets do dashboard (20+)
│ └── [feature]/ # Componentes por feature
├── lib/ # Lógica de negócio
│ ├── auth/ # Auth helpers
│ ├── dashboard/ # Fetchers do dashboard
│ ├── actions/ # Server Actions helpers
│ ├── schemas/ # Zod schemas
│ └── utils/ # Currency, date, period utils
├── db/schema.ts # Drizzle schema
├── hooks/ # React hooks customizados
├── public/ # Assets estáticos
├── scripts/ # Scripts utilitários
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Orquestração
├── public/ # Assets estáticos (imagens, logos, fontes)
├── drizzle/ # Migrations geradas
├── scripts/ # Scripts utilitários (migrations, dev)
├── Dockerfile # Multi-stage build (~200MB, non-root)
├── docker-compose.yml # Orquestração app + PostgreSQL
└── proxy.ts # Middleware (auth + multi-domínio)
```
@@ -291,7 +337,7 @@ openmonetis/
5. **Push:** `git push origin feature/minha-feature`
6. Abra um **Pull Request**
Use TypeScript, commits semânticos e documente features novas.
Antes de começar, leia o [`CLAUDE.md`](CLAUDE.md) — ele documenta a arquitetura, convenções de nomenclatura, regras de queries e o checklist para novas features. Use TypeScript, commits semânticos e mantenha o `CHANGELOG.md` atualizado.
---

View File

@@ -1,11 +0,0 @@
import { LoginForm } from "@/components/auth/login-form";
export default function LoginPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm />
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { SignupForm } from "@/components/auth/signup-form";
export default function Page() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<SignupForm />
</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { desc, eq } from "drizzle-orm";
import { tokensApi } from "@/db/schema";
import { db, schema } from "@/lib/db";
import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index";
export interface UserPreferences {
disableMagnetlines: boolean;
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: FontKey;
moneyFont: FontKey;
}
export interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
export async function fetchAuthProvider(userId: string): Promise<string> {
const userAccount = await db.query.account.findFirst({
where: eq(schema.account.userId, userId),
});
return userAccount?.providerId || "credential";
}
export async function fetchUserPreferences(
userId: string,
): Promise<UserPreferences | null> {
const result = await db
.select({
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
systemFont: schema.preferenciasUsuario.systemFont,
moneyFont: schema.preferenciasUsuario.moneyFont,
})
.from(schema.preferenciasUsuario)
.where(eq(schema.preferenciasUsuario.userId, userId))
.limit(1);
if (!result[0]) return null;
return {
...result[0],
systemFont: normalizeFontKey(result[0].systemFont),
moneyFont: normalizeFontKey(result[0].moneyFont),
};
}
export async function fetchApiTokens(userId: string): Promise<ApiToken[]> {
return db
.select({
id: tokensApi.id,
name: tokensApi.name,
tokenPrefix: tokensApi.tokenPrefix,
lastUsedAt: tokensApi.lastUsedAt,
lastUsedIp: tokensApi.lastUsedIp,
createdAt: tokensApi.createdAt,
expiresAt: tokensApi.expiresAt,
revokedAt: tokensApi.revokedAt,
})
.from(tokensApi)
.where(eq(tokensApi.userId, userId))
.orderBy(desc(tokensApi.createdAt));
}
export async function fetchAjustesPageData(userId: string) {
const [authProvider, userPreferences, userApiTokens] = await Promise.all([
fetchAuthProvider(userId),
fetchUserPreferences(userId),
fetchApiTokens(userId),
]);
return {
authProvider,
userPreferences,
userApiTokens,
};
}

View File

@@ -1,100 +0,0 @@
import { and, eq } from "drizzle-orm";
import { type Anotacao, anotacoes } from "@/db/schema";
import { db } from "@/lib/db";
export type Task = {
id: string;
text: string;
completed: boolean;
};
export type NoteData = {
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: Task[];
arquivada: boolean;
createdAt: string;
};
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}
export async function fetchAllNotesForUser(
userId: string,
): Promise<{ activeNotes: NoteData[]; archivedNotes: NoteData[] }> {
const [activeNotes, archivedNotes] = await Promise.all([
fetchNotesForUser(userId),
fetchArquivadasForUser(userId),
]);
return { activeNotes, archivedNotes };
}
export async function fetchArquivadasForUser(
userId: string,
): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}

View File

@@ -1,292 +0,0 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { buildInvoicePaymentNote } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
PERIOD_FORMAT_REGEX,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date";
const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
),
paymentDate: z.string().optional(),
});
type UpdateInvoicePaymentStatusInput = z.infer<
typeof updateInvoicePaymentStatusSchema
>;
type ActionResult =
| { success: true; message: string }
| { success: false; error: string };
const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
};
const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true, contaId: true, name: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const existingInvoice = await tx.query.faturas.findFirst({
columns: {
id: true,
},
where: and(
eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id),
eq(faturas.period, data.period),
),
});
if (existingInvoice) {
await tx
.update(faturas)
.set({
paymentStatus: data.status,
})
.where(eq(faturas.id, existingInvoice.id));
} else {
await tx.insert(faturas).values({
cartaoId: data.cartaoId,
period: data.period,
paymentStatus: data.status,
userId: user.id,
});
}
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
await tx
.update(lancamentos)
.set({ isSettled: shouldMarkAsPaid })
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
),
);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
if (shouldMarkAsPaid) {
const [adminShareRow] = await tx
.select({
total: sql<number>`
coalesce(
sum(
case
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
if (adminShare > 0 && card.contaId) {
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
});
const paymentCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos"),
),
});
if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: new Date();
const amount = `-${formatDecimal(adminShare)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
contaId: card.contaId,
categoriaId: paymentCategory?.id ?? null,
pagadorId: adminPagador.id,
};
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
});
if (existingPayment) {
await tx
.update(lancamentos)
.set(payload)
.where(eq(lancamentos.id, existingPayment.id));
} else {
await tx.insert(lancamentos).values(payload);
}
}
}
} else {
await tx
.delete(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
);
}
});
revalidateForEntity("cartoes");
return { success: true, message: successMessageByStatus[data.status] };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}
const updatePaymentDateSchema = z.object({
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
});
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
export async function updatePaymentDateAction(
input: UpdatePaymentDateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updatePaymentDateSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
});
if (!existingPayment) {
throw new Error("Pagamento não encontrado.");
}
await tx
.update(lancamentos)
.set({
purchaseDate: parseLocalDateString(data.paymentDate),
})
.where(eq(lancamentos.id, existingPayment.id));
});
revalidateForEntity("cartoes");
return { success: true, message: "Data de pagamento atualizada." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -1,242 +0,0 @@
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
export type CardData = {
id: string;
name: string;
brand: string | null;
status: string | null;
closingDay: number;
dueDay: number;
note: string | null;
logo: string | null;
limit: number | null;
limitInUse: number;
limitAvailable: number | null;
contaId: string;
contaName: string;
};
export type AccountSimple = {
id: string;
name: string;
logo: string | null;
};
export async function fetchCardsForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(
eq(cartoes.userId, userId),
not(ilike(cartoes.status, "inativo")),
),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (
account: typeof contas.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
or(
ne(lancamentos.condition, "Recorrente"),
sql`${lancamentos.purchaseDate} <= current_date`,
),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
},
);
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards, accounts, logoOptions };
}
export async function fetchInativosForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (
account: typeof contas.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
or(
ne(lancamentos.condition, "Recorrente"),
sql`${lancamentos.purchaseDate} <= current_date`,
),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
},
);
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards, accounts, logoOptions };
}
export async function fetchAllCardsForUser(userId: string): Promise<{
activeCards: CardData[];
archivedCards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [activeData, archivedData] = await Promise.all([
fetchCardsForUser(userId),
fetchInativosForUser(userId),
]);
return {
activeCards: activeData.cards,
archivedCards: archivedData.cards,
accounts: activeData.accounts,
logoOptions: activeData.logoOptions,
};
}

View File

@@ -1,30 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CartoesLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6 pt-4">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de cartões */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,17 +0,0 @@
import { CategoryHistoryWidget } from "@/components/dashboard/category-history-widget";
import { getUser } from "@/lib/auth/server";
import { fetchCategoryHistory } from "@/lib/dashboard/categories/category-history";
import { getCurrentPeriod } from "@/lib/utils/period";
export default async function HistoricoCategoriasPage() {
const user = await getUser();
const currentPeriod = getCurrentPeriod();
const data = await fetchCategoryHistory(user.id, currentPeriod);
return (
<main>
<CategoryHistoryWidget data={data} />
</main>
);
}

View File

@@ -1,151 +0,0 @@
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type AccountSummaryData = {
openingBalance: number;
currentBalance: number;
totalIncomes: number;
totalExpenses: number;
};
export async function fetchAccountData(userId: string, contaId: string) {
const account = await db.query.contas.findFirst({
columns: {
id: true,
name: true,
accountType: true,
status: true,
initialBalance: true,
logo: true,
note: true,
},
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
});
return account;
}
export async function fetchAccountSummary(
userId: string,
contaId: string,
selectedPeriod: string,
): Promise<AccountSummaryData> {
const [periodSummary] = await db
.select({
netAmount: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
incomes: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount}
else 0
end
),
0
)
`,
expenses: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const [previousRow] = await db
.select({
previousMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId),
lt(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const account = await fetchAccountData(userId, contaId);
if (!account) {
throw new Error("Account not found");
}
const initialBalance = Number(account.initialBalance ?? 0);
const previousMovements = Number(previousRow?.previousMovements ?? 0);
const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 0);
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
const currentBalance = openingBalance + netAmount;
return {
openingBalance,
currentBalance,
totalIncomes,
totalExpenses,
};
}
export async function fetchAccountLancamentos(
filters: SQL[],
settledOnly = true,
) {
const allFilters = settledOnly
? [...filters, eq(lancamentos.isSettled, true)]
: filters;
return db.query.lancamentos.findMany({
where: and(...allFilters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
}

View File

@@ -1,177 +0,0 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { AccountDialog } from "@/components/contas/account-dialog";
import { AccountStatementCard } from "@/components/contas/account-statement-card";
import type { Account } from "@/components/contas/types";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period";
import {
fetchAccountData,
fetchAccountLancamentos,
fetchAccountSummary,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ contaId: string }>;
searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
export default async function Page({ params, searchParams }: PageProps) {
const { contaId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const account = await fetchAccountData(userId, contaId);
if (!account) {
notFound();
}
const [
filterSources,
logoOptions,
accountSummary,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
getRecentEstablishmentsAction(),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
accountId: account.id,
});
const lancamentoRows = await fetchAccountLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
accountSummary;
const periodLabel = `${capitalize(monthName)} de ${year}`;
const accountDialogData: Account = {
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance: currentBalance,
};
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitContaId: account.id,
});
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<AccountStatementCard
accountName={account.name}
accountType={account.accountType}
status={account.status}
periodLabel={periodLabel}
openingBalance={openingBalance}
currentBalance={currentBalance}
totalIncomes={totalIncomes}
totalExpenses={totalExpenses}
logo={account.logo}
actions={
<AccountDialog
mode="update"
account={accountDialogData}
logoOptions={logoOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar conta"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={false}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</section>
</main>
);
}

View File

@@ -1,188 +0,0 @@
import { and, eq, ilike, not, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type AccountData = {
id: string;
name: string;
accountType: string;
status: string;
note: string | null;
logo: string | null;
initialBalance: number;
balance: number;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
};
export async function fetchAccountsForUser(
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
not(ilike(contas.status, "inativa")),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };
}
export async function fetchInativosForUser(
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
ilike(contas.status, "inativa"),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };
}
export async function fetchAllAccountsForUser(userId: string): Promise<{
activeAccounts: AccountData[];
archivedAccounts: AccountData[];
logoOptions: LogoOption[];
}> {
const [activeData, archivedData] = await Promise.all([
fetchAccountsForUser(userId),
fetchInativosForUser(userId),
]);
return {
activeAccounts: activeData.accounts,
archivedAccounts: archivedData.accounts,
logoOptions: activeData.logoOptions,
};
}

View File

@@ -1,33 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ContasLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6 pt-4">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de contas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<div className="flex gap-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,25 +0,0 @@
import { eq } from "drizzle-orm";
import { db, schema } from "@/lib/db";
export interface UserDashboardPreferences {
disableMagnetlines: boolean;
dashboardWidgets: string | null;
}
export async function fetchUserDashboardPreferences(
userId: string,
): Promise<UserDashboardPreferences> {
const result = await db
.select({
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets,
})
.from(schema.preferenciasUsuario)
.where(eq(schema.preferenciasUsuario.userId, userId))
.limit(1);
return {
disableMagnetlines: result[0]?.disableMagnetlines ?? false,
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
};
}

View File

@@ -1,17 +0,0 @@
import { DashboardGridSkeleton } from "@/components/shared/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardLoading() {
return (
<main className="flex flex-col gap-4">
{/* Welcome Banner skeleton */}
<Skeleton className="h-[104px] w-full rounded-xl bg-foreground/10" />
{/* Month Picker skeleton */}
<Skeleton className="h-[56px] w-full rounded-xl bg-foreground/10" />
{/* Dashboard content skeleton (Section Cards + Widget Grid) */}
<DashboardGridSkeleton />
</main>
);
}

View File

@@ -1,82 +0,0 @@
import { DashboardGridEditable } from "@/components/dashboard/dashboard-grid-editable";
import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome";
import { SectionCards } from "@/components/dashboard/section-cards";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { getRecentEstablishmentsAction } from "../lancamentos/actions";
import { fetchUserDashboardPreferences } from "./data";
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) {
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [dashboardData, preferences, filterSources, estabelecimentos] =
await Promise.all([
fetchDashboardData(user.id, selectedPeriod),
fetchUserDashboardPreferences(user.id),
fetchLancamentoFilterSources(user.id),
getRecentEstablishmentsAction(),
]);
const { disableMagnetlines, dashboardWidgets } = preferences;
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
return (
<main className="flex flex-col gap-4">
<DashboardWelcome
name={user.name}
disableMagnetlines={disableMagnetlines}
/>
<MonthNavigation />
<SectionCards metrics={dashboardData.metrics} />
<DashboardGridEditable
data={dashboardData}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={{
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
}}
/>
</main>
);
}

View File

@@ -1,871 +0,0 @@
"use server";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { getDay } from "date-fns";
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
insightsSalvos,
lancamentos,
orcamentos,
pagadores,
} from "@/db/schema";
import { getUser } from "@/lib/auth/server";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {
type InsightsResponse,
InsightsResponseSchema,
} from "@/lib/schemas/insights";
import { getPreviousPeriod } from "@/lib/utils/period";
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data";
const TRANSFERENCIA = "Transferência";
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
/**
* Função auxiliar para converter valores numéricos
*/
const toNumber = (value: unknown): number => {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
};
/**
* Agrega dados financeiros do mês para análise
*/
async function aggregateMonthData(userId: string, period: string) {
const previousPeriod = getPreviousPeriod(period);
const twoMonthsAgo = getPreviousPeriod(previousPeriod);
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
// Buscar métricas de receitas e despesas dos últimos 3 meses
const [
currentPeriodRows,
previousPeriodRows,
twoMonthsAgoRows,
threeMonthsAgoRows,
] = await Promise.all([
db
.select({
transactionType: lancamentos.transactionType,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
db
.select({
transactionType: lancamentos.transactionType,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
db
.select({
transactionType: lancamentos.transactionType,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, twoMonthsAgo),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
db
.select({
transactionType: lancamentos.transactionType,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, threeMonthsAgo),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
]);
// Calcular totais dos últimos 3 meses
let currentIncome = 0;
let currentExpense = 0;
let previousIncome = 0;
let previousExpense = 0;
let twoMonthsAgoIncome = 0;
let twoMonthsAgoExpense = 0;
let threeMonthsAgoIncome = 0;
let threeMonthsAgoExpense = 0;
for (const row of currentPeriodRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") currentIncome += amount;
else if (row.transactionType === "Despesa") currentExpense += amount;
}
for (const row of previousPeriodRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") previousIncome += amount;
else if (row.transactionType === "Despesa") previousExpense += amount;
}
for (const row of twoMonthsAgoRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") twoMonthsAgoIncome += amount;
else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount;
}
for (const row of threeMonthsAgoRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") threeMonthsAgoIncome += amount;
else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount;
}
// Buscar despesas por categoria (top 5)
const expensesByCategory = await db
.select({
categoryName: categorias.name,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(categorias.name)
.orderBy(sql`sum(${lancamentos.amount}) ASC`)
.limit(5);
// Buscar orçamentos e uso
const budgetsData = await db
.select({
categoryName: categorias.name,
budgetAmount: orcamentos.amount,
spent: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.leftJoin(
lancamentos,
and(
eq(lancamentos.categoriaId, categorias.id),
eq(lancamentos.period, period),
eq(lancamentos.userId, userId),
eq(lancamentos.transactionType, "Despesa"),
),
)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
.groupBy(categorias.name, orcamentos.amount);
// Buscar métricas de cartões
const cardsData = await db
.select({
totalLimit: sql<number>`coalesce(sum(${cartoes.limit}), 0)`,
cardCount: sql<number>`count(*)`,
})
.from(cartoes)
.where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")));
// Buscar saldo total das contas
const accountsData = await db
.select({
totalBalance: sql<number>`coalesce(sum(${contas.initialBalance}), 0)`,
accountCount: sql<number>`count(*)`,
})
.from(contas)
.where(
and(
eq(contas.userId, userId),
eq(contas.status, "ativa"),
eq(contas.excludeFromBalance, false),
),
);
// Calcular ticket médio das transações
const avgTicketData = await db
.select({
avgAmount: sql<number>`coalesce(avg(abs(${lancamentos.amount})), 0)`,
transactionCount: sql<number>`count(*)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
),
);
// Buscar gastos por dia da semana
const dayOfWeekSpending = await db
.select({
purchaseDate: lancamentos.purchaseDate,
amount: lancamentos.amount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
// Agregar por dia da semana
const dayTotals = new Map<number, number>();
for (const row of dayOfWeekSpending) {
if (!row.purchaseDate) continue;
const dayOfWeek = getDay(new Date(row.purchaseDate));
const current = dayTotals.get(dayOfWeek) ?? 0;
dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount)));
}
// Buscar métodos de pagamento (agregado)
const paymentMethodsData = await db
.select({
paymentMethod: lancamentos.paymentMethod,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
)
.groupBy(lancamentos.paymentMethod);
// Buscar transações dos últimos 3 meses para análise de recorrência
const last3MonthsTransactions = await db
.select({
name: lancamentos.name,
amount: lancamentos.amount,
period: lancamentos.period,
condition: lancamentos.condition,
installmentCount: lancamentos.installmentCount,
currentInstallment: lancamentos.currentInstallment,
categoryName: categorias.name,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`,
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
),
)
.orderBy(lancamentos.name);
// Análise de recorrência
const transactionsByName = new Map<
string,
Array<{ period: string; amount: number }>
>();
for (const tx of last3MonthsTransactions) {
const key = tx.name.toLowerCase().trim();
if (!transactionsByName.has(key)) {
transactionsByName.set(key, []);
}
const transactions = transactionsByName.get(key);
if (transactions) {
transactions.push({
period: tx.period,
amount: Math.abs(toNumber(tx.amount)),
});
}
}
// Identificar gastos recorrentes (aparece em 2+ meses com valor similar)
const recurringExpenses: Array<{
name: string;
avgAmount: number;
frequency: number;
}> = [];
let totalRecurring = 0;
for (const [name, occurrences] of transactionsByName.entries()) {
if (occurrences.length >= 2) {
const amounts = occurrences.map((o) => o.amount);
const avgAmount =
amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
const maxDiff = Math.max(...amounts) - Math.min(...amounts);
// Considerar recorrente se variação <= 20% da média
if (maxDiff <= avgAmount * 0.2) {
recurringExpenses.push({
name,
avgAmount,
frequency: occurrences.length,
});
// Somar apenas os do mês atual
const currentMonthOccurrence = occurrences.find(
(o) => o.period === period,
);
if (currentMonthOccurrence) {
totalRecurring += currentMonthOccurrence.amount;
}
}
}
}
// Análise de gastos parcelados
const installmentTransactions = last3MonthsTransactions.filter(
(tx) =>
tx.condition === "Parcelado" &&
tx.installmentCount &&
tx.installmentCount > 1,
);
const installmentData = installmentTransactions
.filter((tx) => tx.period === period)
.map((tx) => ({
name: tx.name,
currentInstallment: tx.currentInstallment ?? 1,
totalInstallments: tx.installmentCount ?? 1,
amount: Math.abs(toNumber(tx.amount)),
category: tx.categoryName ?? "Outros",
}));
const totalInstallmentAmount = installmentData.reduce(
(sum, tx) => sum + tx.amount,
0,
);
const futureCommitment = installmentData.reduce((sum, tx) => {
const remaining = tx.totalInstallments - tx.currentInstallment;
return sum + tx.amount * remaining;
}, 0);
// Montar dados agregados e anonimizados
const aggregatedData = {
month: period,
totalIncome: currentIncome,
totalExpense: currentExpense,
balance: currentIncome - currentExpense,
// Tendência de 3 meses
threeMonthTrend: {
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
incomes: [
threeMonthsAgoIncome,
twoMonthsAgoIncome,
previousIncome,
currentIncome,
],
expenses: [
threeMonthsAgoExpense,
twoMonthsAgoExpense,
previousExpense,
currentExpense,
],
avgIncome:
(threeMonthsAgoIncome +
twoMonthsAgoIncome +
previousIncome +
currentIncome) /
4,
avgExpense:
(threeMonthsAgoExpense +
twoMonthsAgoExpense +
previousExpense +
currentExpense) /
4,
trend:
currentExpense > previousExpense &&
previousExpense > twoMonthsAgoExpense
? "crescente"
: currentExpense < previousExpense &&
previousExpense < twoMonthsAgoExpense
? "decrescente"
: "estável",
},
previousMonthIncome: previousIncome,
previousMonthExpense: previousExpense,
monthOverMonthIncomeChange:
Math.abs(previousIncome) > 0.01
? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100
: 0,
monthOverMonthExpenseChange:
Math.abs(previousExpense) > 0.01
? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100
: 0,
savingsRate:
currentIncome > 0.01
? ((currentIncome - currentExpense) / currentIncome) * 100
: 0,
topExpenseCategories: expensesByCategory.map(
(cat: { categoryName: string; total: unknown }) => ({
category: cat.categoryName,
amount: Math.abs(toNumber(cat.total)),
percentageOfTotal:
currentExpense > 0
? (Math.abs(toNumber(cat.total)) / currentExpense) * 100
: 0,
}),
),
budgets: budgetsData.map(
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
category: b.categoryName,
budgetAmount: toNumber(b.budgetAmount),
spent: Math.abs(toNumber(b.spent)),
usagePercentage:
toNumber(b.budgetAmount) > 0
? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100
: 0,
}),
),
creditCards: {
totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0),
cardCount: toNumber(cardsData[0]?.cardCount ?? 0),
},
accounts: {
totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0),
accountCount: toNumber(accountsData[0]?.accountCount ?? 0),
},
avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0),
transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0),
dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({
dayOfWeek:
["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A",
total,
})),
paymentMethodsBreakdown: paymentMethodsData.map(
(pm: { paymentMethod: string | null; total: unknown }) => ({
method: pm.paymentMethod,
total: toNumber(pm.total),
percentage:
currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0,
}),
),
// Análise de recorrência
recurringExpenses: {
count: recurringExpenses.length,
total: totalRecurring,
percentageOfTotal:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
topRecurring: recurringExpenses
.sort((a, b) => b.avgAmount - a.avgAmount)
.slice(0, 5)
.map((r) => ({
name: r.name,
avgAmount: r.avgAmount,
frequency: r.frequency,
})),
predictability:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
},
// Análise de parcelamentos
installments: {
currentMonthInstallments: installmentData.length,
totalInstallmentAmount,
percentageOfExpenses:
currentExpense > 0
? (totalInstallmentAmount / currentExpense) * 100
: 0,
futureCommitment,
topInstallments: installmentData
.sort((a, b) => b.amount - a.amount)
.slice(0, 5)
.map((i) => ({
name: i.name,
current: i.currentInstallment,
total: i.totalInstallments,
amount: i.amount,
category: i.category,
remaining: i.totalInstallments - i.currentInstallment,
})),
},
};
return aggregatedData;
}
/**
* Gera insights usando IA
*/
export async function generateInsightsAction(
period: string,
modelId: string,
): Promise<ActionResult<InsightsResponse>> {
try {
const user = await getUser();
// Validar modelo - verificar se existe na lista ou se é um modelo customizado
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
// Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido
const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(
modelId,
);
if (!selectedModel && !isOpenRouterFormat) {
return {
success: false,
error: "Modelo inválido.",
};
}
// Agregar dados
const aggregatedData = await aggregateMonthData(user.id, period);
// Selecionar provider
let model: ReturnType<typeof google>;
// Se o modelo tem "/" é OpenRouter (formato: provider/model)
if (isOpenRouterFormat && !selectedModel) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return {
success: false,
error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
};
}
const openrouter = createOpenRouter({
apiKey,
});
model = openrouter.chat(modelId);
} else if (selectedModel?.provider === "openai") {
model = openai(modelId);
} else if (selectedModel?.provider === "anthropic") {
model = anthropic(modelId);
} else if (selectedModel?.provider === "google") {
model = google(modelId);
} else {
return {
success: false,
error: "Provider de modelo não suportado.",
};
}
// Chamar AI SDK
const result = await generateObject({
model,
schema: InsightsResponseSchema,
system: INSIGHTS_SYSTEM_PROMPT,
prompt: `Analise os seguintes dados financeiros agregados do período ${period}.
Dados agregados:
${JSON.stringify(aggregatedData, null, 2)}
DADOS IMPORTANTES PARA SUA ANÁLISE:
**Tendência de 3 meses:**
- Os dados incluem tendência dos últimos 3 meses (threeMonthTrend)
- Use isso para identificar padrões crescentes, decrescentes ou estáveis
- Compare o mês atual com a média dos 3 meses
**Análise de Recorrência:**
- Gastos recorrentes representam ${aggregatedData.recurringExpenses.percentageOfTotal.toFixed(1)}% das despesas
- ${aggregatedData.recurringExpenses.count} gastos identificados como recorrentes
- Use isso para avaliar previsibilidade e oportunidades de otimização
**Gastos Parcelados:**
- ${aggregatedData.installments.currentMonthInstallments} parcelas ativas no mês
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
- Use isso para alertas sobre comprometimento de renda futura
Organize suas observações nas 4 categorias especificadas no prompt do sistema:
1. Comportamentos Observados (behaviors): 3-6 itens
2. Gatilhos de Consumo (triggers): 3-6 itens
3. Recomendações Práticas (recommendations): 3-6 itens
4. Melhorias Sugeridas (improvements): 3-6 itens
Cada item deve ser conciso, direto e acionável. Use os novos dados para dar contexto temporal e identificar padrões mais profundos.
Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`,
});
// Validar resposta
const validatedData = InsightsResponseSchema.parse(result.object);
return {
success: true,
data: validatedData,
};
} catch (error) {
console.error("Error generating insights:", error);
return {
success: false,
error: "Erro ao gerar insights. Tente novamente.",
};
}
}
/**
* Salva insights gerados no banco de dados
*/
export async function saveInsightsAction(
period: string,
modelId: string,
data: InsightsResponse,
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
try {
const user = await getUser();
// Verificar se já existe um insight salvo para este período
const existing = await db
.select()
.from(insightsSalvos)
.where(
and(
eq(insightsSalvos.userId, user.id),
eq(insightsSalvos.period, period),
),
)
.limit(1);
if (existing.length > 0) {
// Atualizar existente
const updated = await db
.update(insightsSalvos)
.set({
modelId,
data: JSON.stringify(data),
updatedAt: new Date(),
})
.where(
and(
eq(insightsSalvos.userId, user.id),
eq(insightsSalvos.period, period),
),
)
.returning({
id: insightsSalvos.id,
createdAt: insightsSalvos.createdAt,
});
const updatedRecord = updated[0];
if (!updatedRecord) {
return {
success: false,
error: "Falha ao atualizar a análise. Tente novamente.",
};
}
return {
success: true,
data: {
id: updatedRecord.id,
createdAt: updatedRecord.createdAt,
},
};
}
// Criar novo
const result = await db
.insert(insightsSalvos)
.values({
userId: user.id,
period,
modelId,
data: JSON.stringify(data),
})
.returning({
id: insightsSalvos.id,
createdAt: insightsSalvos.createdAt,
});
const insertedRecord = result[0];
if (!insertedRecord) {
return {
success: false,
error: "Falha ao salvar a análise. Tente novamente.",
};
}
return {
success: true,
data: {
id: insertedRecord.id,
createdAt: insertedRecord.createdAt,
},
};
} catch (error) {
console.error("Error saving insights:", error);
return {
success: false,
error: "Erro ao salvar análise. Tente novamente.",
};
}
}
/**
* Carrega insights salvos do banco de dados
*/
export async function loadSavedInsightsAction(period: string): Promise<
ActionResult<{
insights: InsightsResponse;
modelId: string;
createdAt: Date;
} | null>
> {
try {
const user = await getUser();
const result = await db
.select()
.from(insightsSalvos)
.where(
and(
eq(insightsSalvos.userId, user.id),
eq(insightsSalvos.period, period),
),
)
.limit(1);
if (result.length === 0) {
return {
success: true,
data: null,
};
}
const saved = result[0];
if (!saved) {
return {
success: true,
data: null,
};
}
const insights = InsightsResponseSchema.parse(JSON.parse(saved.data));
return {
success: true,
data: {
insights,
modelId: saved.modelId,
createdAt: saved.createdAt,
},
};
} catch (error) {
console.error("Error loading saved insights:", error);
return {
success: false,
error: "Erro ao carregar análise salva. Tente novamente.",
};
}
}
/**
* Remove insights salvos do banco de dados
*/
export async function deleteSavedInsightsAction(
period: string,
): Promise<ActionResult<void>> {
try {
const user = await getUser();
await db
.delete(insightsSalvos)
.where(
and(
eq(insightsSalvos.userId, user.id),
eq(insightsSalvos.period, period),
),
);
return {
success: true,
data: undefined,
};
} catch (error) {
console.error("Error deleting saved insights:", error);
return {
success: false,
error: "Erro ao remover análise. Tente novamente.",
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { db } from "@/lib/db";
export async function fetchLancamentos(filters: SQL[]) {
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
...filters,
// Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
}

View File

@@ -1,92 +0,0 @@
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { getRecentEstablishmentsAction } from "./actions";
import { fetchLancamentos } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const [filterSources, userPreferences] = await Promise.all([
fetchLancamentoFilterSources(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
});
const lancamentoRows = await fetchLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const estabelecimentos = await getRecentEstablishmentsAction();
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<LancamentosPage
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</main>
);
}

View File

@@ -1,73 +0,0 @@
import { FontProvider } from "@/components/font-provider";
import { AppNavbar } from "@/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/components/privacy-provider";
import { getUserSession } from "@/lib/auth/server";
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { fetchUserFontPreferences } from "@/lib/preferences/fonts";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
export default async function DashboardLayout({
children,
searchParams,
}: Readonly<{
children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}>) {
const session = await getUserSession();
const pagadoresList = await fetchPagadoresWithAccess(session.user.id);
// Encontrar o pagador admin do usuário
const adminPagador = pagadoresList.find(
(p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === 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 notificationsSnapshot = await fetchDashboardNotifications(
session.user.id,
currentPeriod,
);
// Buscar contagem de pré-lançamentos pendentes e preferências de fonte
const [preLancamentosCount, fontPrefs] = await Promise.all([
fetchPendingInboxCount(session.user.id),
fetchUserFontPreferences(session.user.id),
]);
return (
<FontProvider
systemFont={fontPrefs.systemFont}
moneyFont={fontPrefs.moneyFont}
>
<PrivacyProvider>
<AppNavbar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
preLancamentosCount={preLancamentosCount}
notificationsSnapshot={notificationsSnapshot}
/>
<div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-primary/5 via-transparent to-transparent" />
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4">
{children}
</div>
</div>
</div>
</PrivacyProvider>
</FontProvider>
);
}

View File

@@ -1,274 +0,0 @@
"use server";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import {
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/lib/utils/currency";
const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"),
period: periodSchema,
amount: z
.string({ message: "Informe o valor limite." })
.trim()
.min(1, "Informe o valor limite.")
.transform((value) => normalizeDecimalInput(value))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor limite válido.",
)
.transform((value) => Number.parseFloat(value))
.refine(
(value) => value >= 0,
"O valor limite deve ser maior ou igual a zero.",
),
});
const createBudgetSchema = budgetBaseSchema;
const updateBudgetSchema = budgetBaseSchema.extend({
id: uuidSchema("Orçamento"),
});
const deleteBudgetSchema = z.object({
id: uuidSchema("Orçamento"),
});
type BudgetCreateInput = z.infer<typeof createBudgetSchema>;
type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>;
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>;
const ensureCategory = async (userId: string, categoriaId: string) => {
const category = await db.query.categorias.findFirst({
columns: {
id: true,
type: true,
},
where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
});
if (!category) {
throw new Error("Categoria não encontrada.");
}
if (category.type !== "despesa") {
throw new Error("Selecione uma categoria de despesa.");
}
};
export async function createBudgetAction(
input: BudgetCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createBudgetSchema.parse(input);
await ensureCategory(user.id, data.categoriaId);
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
] as const;
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
await db.insert(orcamentos).values({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
userId: user.id,
categoriaId: data.categoriaId,
});
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateBudgetAction(
input: BudgetUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateBudgetSchema.parse(input);
await ensureCategory(user.id, data.categoriaId);
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
ne(orcamentos.id, data.id),
] as const;
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
const [updated] = await db
.update(orcamentos)
.set({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
categoriaId: data.categoriaId,
})
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
if (!updated) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteBudgetAction(
input: BudgetDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteBudgetSchema.parse(input);
const [deleted] = await db
.delete(orcamentos)
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
if (!deleted) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
});
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>;
export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = duplicatePreviousMonthSchema.parse(input);
// Calcular mês anterior
const [year, month] = data.period.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
const previousDate = new Date(currentDate);
previousDate.setMonth(previousDate.getMonth() - 1);
const prevYear = previousDate.getFullYear();
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
const previousPeriod = `${prevYear}-${prevMonth}`;
// Buscar orçamentos do mês anterior
const previousBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod),
),
});
if (previousBudgets.length === 0) {
return {
success: false,
error: "Não foram encontrados orçamentos no mês anterior.",
};
}
// Buscar orçamentos existentes do mês atual
const currentBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
),
});
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(
currentBudgets.map((b) => b.categoriaId),
);
const budgetsToCopy = previousBudgets.filter(
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId),
);
if (budgetsToCopy.length === 0) {
return {
success: false,
error:
"Todas as categorias do mês anterior já possuem orçamento neste mês.",
};
}
// Inserir novos orçamentos
await db.insert(orcamentos).values(
budgetsToCopy.map((b) => ({
amount: b.amount,
period: data.period,
userId: user.id,
categoriaId: b.categoriaId as string,
})),
);
revalidateForEntity("orcamentos");
return {
success: true,
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,98 +0,0 @@
import { and, desc, eq, type SQL } from "drizzle-orm";
import {
cartoes,
categorias,
compartilhamentosPagador,
contas,
lancamentos,
pagadores,
user as usersTable,
} from "@/db/schema";
import { db } from "@/lib/db";
export type ShareData = {
id: string;
userId: string;
name: string;
email: string;
createdAt: string;
};
export async function fetchPagadorShares(
pagadorId: string,
): Promise<ShareData[]> {
const shareRows = await db
.select({
id: compartilhamentosPagador.id,
sharedWithUserId: compartilhamentosPagador.sharedWithUserId,
createdAt: compartilhamentosPagador.createdAt,
userName: usersTable.name,
userEmail: usersTable.email,
})
.from(compartilhamentosPagador)
.innerJoin(
usersTable,
eq(compartilhamentosPagador.sharedWithUserId, usersTable.id),
)
.where(eq(compartilhamentosPagador.pagadorId, pagadorId));
return shareRows.map((share) => ({
id: share.id,
userId: share.sharedWithUserId,
name: share.userName ?? "Usuário",
email: share.userEmail ?? "email não informado",
createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(),
}));
}
export async function fetchCurrentUserShare(
pagadorId: string,
userId: string,
): Promise<{ id: string; createdAt: string } | null> {
const shareRow = await db.query.compartilhamentosPagador.findFirst({
columns: {
id: true,
createdAt: true,
},
where: and(
eq(compartilhamentosPagador.pagadorId, pagadorId),
eq(compartilhamentosPagador.sharedWithUserId, userId),
),
});
if (!shareRow) {
return null;
}
return {
id: shareRow.id,
createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
};
}
export async function fetchPagadorLancamentos(filters: SQL[]) {
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...filters))
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row: Record<string, unknown>) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
}

View File

@@ -1,84 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de detalhes do pagador
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
*/
export default function PagadorDetailsLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Info do Pagador (sempre visível) */}
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
{/* Nome + Badge */}
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Email */}
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Botões de ação */}
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Tabs */}
<div className="space-y-6 pt-4">
<div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
</div>
{/* Conteúdo da aba Visão Geral (grid de cards) */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Card de resumo mensal */}
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
{/* Outros cards */}
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-3 pt-4">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-3/4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,490 +0,0 @@
import {
RiBankCard2Line,
RiBarcodeLine,
RiWallet3Line,
} from "@remixicon/react";
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type {
ContaCartaoFilterOption,
LancamentoFilterOption,
LancamentoItem,
SelectOption,
} from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import {
PagadorBoletoCard,
PagadorPaymentStatusCard,
} from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import WidgetCard from "@/components/widget-card";
import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
type LancamentoSearchFilters,
mapLancamentosData,
type ResolvedSearchParams,
type SluggedFilters,
type SlugMaps,
} from "@/lib/lancamentos/page-helpers";
import { getPagadorAccess } from "@/lib/pagadores/access";
import {
fetchPagadorBoletoItems,
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
fetchPagadorPaymentStatus,
} from "@/lib/pagadores/details";
import { parsePeriodParam } from "@/lib/utils/period";
import {
fetchCurrentUserShare,
fetchPagadorLancamentos,
fetchPagadorShares,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ pagadorId: string }>;
searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value;
const EMPTY_FILTERS: LancamentoSearchFilters = {
transactionFilter: null,
conditionFilter: null,
paymentFilter: null,
pagadorFilter: null,
categoriaFilter: null,
contaCartaoFilter: null,
searchFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({
pagador: new Map(),
categoria: new Map(),
conta: new Map(),
cartao: new Map(),
});
type OptionSet = ReturnType<typeof buildOptionSets>;
export default async function Page({ params, searchParams }: PageProps) {
const { pagadorId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const access = await getPagadorAccess(userId, pagadorId);
if (!access) {
notFound();
}
const { pagador, canEdit } = access;
const dataOwnerId = pagador.userId;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const periodLabel = `${capitalize(monthName)} de ${year}`;
const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = canEdit
? allSearchFilters
: {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
let filterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let loggedUserFilterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let sluggedFilters: SluggedFilters;
let slugMaps: SlugMaps;
if (canEdit) {
filterSources = await fetchLancamentoFilterSources(dataOwnerId);
sluggedFilters = buildSluggedFilters(filterSources);
slugMaps = buildSlugMaps(sluggedFilters);
} else {
// Buscar opções do usuário logado para usar ao importar
loggedUserFilterSources = await fetchLancamentoFilterSources(userId);
sluggedFilters = {
pagadorFiltersRaw: [],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
slugMaps = createEmptySlugMaps();
}
const filters = buildLancamentoWhere({
userId: dataOwnerId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
pagadorId: pagador.id,
});
const sharesPromise = canEdit
? fetchPagadorShares(pagador.id)
: Promise.resolve([]);
const currentUserSharePromise = !canEdit
? fetchCurrentUserShare(pagador.id, userId)
: Promise.resolve(null);
const [
lancamentoRows,
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletoItems,
paymentStatus,
shareRows,
currentUserShare,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchPagadorLancamentos(filters),
fetchPagadorMonthlyBreakdown({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorHistory({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorCardUsage({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoStats({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoItems({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorPaymentStatus({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
sharesPromise,
currentUserSharePromise,
getRecentEstablishmentsAction(),
fetchUserPreferences(userId),
]);
const mappedLancamentos = mapLancamentosData(lancamentoRows);
const lancamentosData = canEdit
? mappedLancamentos
: mappedLancamentos.map((item) => ({ ...item, readonly: true }));
const pagadorSharesData = shareRows;
let optionSets: OptionSet;
let loggedUserOptionSets: OptionSet | null = null;
let effectiveSluggedFilters = sluggedFilters;
if (canEdit && filterSources) {
optionSets = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
} else {
effectiveSluggedFilters = {
pagadorFiltersRaw: [
{
id: pagador.id,
label: pagador.name,
slug: pagador.id,
role: pagador.role,
},
],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
optionSets = buildReadOnlyOptionSets(lancamentosData, pagador);
// Construir opções do usuário logado para usar ao importar
if (loggedUserFilterSources) {
const loggedUserSluggedFilters = buildSluggedFilters(
loggedUserFilterSources,
);
loggedUserOptionSets = buildOptionSets({
...loggedUserSluggedFilters,
pagadorRows: loggedUserFilterSources.pagadorRows,
});
}
}
const pagadorSlug =
effectiveSluggedFilters.pagadorFiltersRaw.find(
(item) => item.id === pagador.id,
)?.slug ?? null;
const pagadorFilterOptions = pagadorSlug
? optionSets.pagadorFilterOptions.filter(
(option) => option.slug === pagadorSlug,
)
: optionSets.pagadorFilterOptions;
const pagadorData = {
id: pagador.id,
name: pagador.name,
email: pagador.email ?? null,
avatarUrl: pagador.avatarUrl ?? null,
status: pagador.status,
note: pagador.note ?? null,
role: pagador.role ?? null,
isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt
? pagador.createdAt.toISOString()
: new Date().toISOString(),
lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null,
shareCode: canEdit ? pagador.shareCode : null,
canEdit,
};
const summaryPreview = {
periodLabel,
totalExpenses: monthlyBreakdown.totalExpenses,
paymentSplits: monthlyBreakdown.paymentSplits,
cardUsage: cardUsage.slice(0, 3).map((item) => ({
name: item.name,
amount: item.amount,
})),
boletoStats: {
totalAmount: boletoStats.totalAmount,
paidAmount: boletoStats.paidAmount,
pendingAmount: boletoStats.pendingAmount,
paidCount: boletoStats.paidCount,
pendingCount: boletoStats.pendingCount,
},
lancamentoCount: lancamentosData.length,
};
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-2">
<TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="painel">Painel</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4">
<section>
<PagadorInfoCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
</section>
{canEdit && pagadorData.shareCode ? (
<PagadorSharingCard
pagadorId={pagador.id}
shareCode={pagadorData.shareCode}
shares={pagadorSharesData}
/>
) : null}
{!canEdit && currentUserShare ? (
<PagadorLeaveShareCard
shareId={currentUserShare.id}
pagadorName={pagadorData.name}
createdAt={currentUserShare.createdAt}
/>
) : null}
</TabsContent>
<TabsContent value="painel" className="space-y-4">
<section className="grid gap-3 lg:grid-cols-2">
<PagadorMonthlySummaryCard
periodLabel={periodLabel}
breakdown={monthlyBreakdown}
/>
<PagadorHistoryCard data={historyData} />
</section>
<section className="grid gap-3 lg:grid-cols-3">
<WidgetCard
title="Minhas Faturas"
subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />}
>
<PagadorCardUsageCard items={cardUsage} />
</WidgetCard>
<WidgetCard
title="Boletos"
subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />}
>
<PagadorBoletoCard items={boletoItems} />
</WidgetCard>
<WidgetCard
title="Status de Pagamento"
subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />}
>
<PagadorPaymentStatusCard data={paymentStatus} />
</WidgetCard>
</section>
</TabsContent>
<TabsContent value="lancamentos">
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={optionSets.pagadorOptions}
splitPagadorOptions={optionSets.splitPagadorOptions}
defaultPagadorId={pagador.id}
contaOptions={optionSets.contaOptions}
cartaoOptions={optionSets.cartaoOptions}
categoriaOptions={optionSets.categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={optionSets.categoriaFilterOptions}
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={canEdit}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={
loggedUserOptionSets?.splitPagadorOptions
}
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
importContaOptions={loggedUserOptionSets?.contaOptions}
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
importCategoriaOptions={loggedUserOptionSets?.categoriaOptions}
/>
</section>
</TabsContent>
</Tabs>
</main>
);
}
const normalizeOptionLabel = (
value: string | null | undefined,
fallback: string,
) => (value?.trim().length ? value.trim() : fallback);
function buildReadOnlyOptionSets(
items: LancamentoItem[],
pagador: typeof pagadores.$inferSelect,
): OptionSet {
const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
const pagadorOptions: SelectOption[] = [
{
value: pagador.id,
label: pagadorLabel,
slug: pagador.id,
},
];
const contaOptionsMap = new Map<string, SelectOption>();
const cartaoOptionsMap = new Map<string, SelectOption>();
const categoriaOptionsMap = new Map<string, SelectOption>();
items.forEach((item) => {
if (item.contaId && !contaOptionsMap.has(item.contaId)) {
contaOptionsMap.set(item.contaId, {
value: item.contaId,
label: normalizeOptionLabel(item.contaName, "Conta sem nome"),
slug: item.contaId,
});
}
if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) {
cartaoOptionsMap.set(item.cartaoId, {
value: item.cartaoId,
label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"),
slug: item.cartaoId,
});
}
if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) {
categoriaOptionsMap.set(item.categoriaId, {
value: item.categoriaId,
label: normalizeOptionLabel(item.categoriaName, "Categoria"),
slug: item.categoriaId,
});
}
});
const contaOptions = Array.from(contaOptionsMap.values());
const cartaoOptions = Array.from(cartaoOptionsMap.values());
const categoriaOptions = Array.from(categoriaOptionsMap.values());
const pagadorFilterOptions: LancamentoFilterOption[] = [
{ slug: pagador.id, label: pagadorLabel },
];
const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map(
(option) => ({
slug: option.value,
label: option.label,
}),
);
const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [
...contaOptions.map((option) => ({
slug: option.value,
label: option.label,
kind: "conta" as const,
})),
...cartaoOptions.map((option) => ({
slug: option.value,
label: option.label,
kind: "cartao" as const,
})),
];
return {
pagadorOptions,
splitPagadorOptions: [],
defaultPagadorId: pagador.id,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
};
}

View File

@@ -1,14 +0,0 @@
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
import { getUserId } from "@/lib/auth/server";
import { fetchPagadoresForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const { pagadores, avatarOptions } = await fetchPagadoresForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<PagadoresPage pagadores={pagadores} avatarOptions={avatarOptions} />
</main>
);
}

View File

@@ -1,195 +0,0 @@
import { and, desc, eq, gte } from "drizzle-orm";
import type {
InboxItem,
SelectOption,
} from "@/components/pre-lancamentos/types";
import {
cartoes,
categorias,
contas,
lancamentos,
preLancamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
export async function fetchInboxItems(
userId: string,
status: "pending" | "processed" | "discarded" = "pending",
): Promise<InboxItem[]> {
const items = await db
.select()
.from(preLancamentos)
.where(
and(eq(preLancamentos.userId, userId), eq(preLancamentos.status, status)),
)
.orderBy(desc(preLancamentos.createdAt));
return items;
}
export async function fetchInboxItemById(
userId: string,
itemId: string,
): Promise<InboxItem | null> {
const [item] = await db
.select()
.from(preLancamentos)
.where(
and(eq(preLancamentos.id, itemId), eq(preLancamentos.userId, userId)),
)
.limit(1);
return item ?? null;
}
export async function fetchCategoriasForSelect(
userId: string,
type?: string,
): Promise<SelectOption[]> {
const query = db
.select({ id: categorias.id, name: categorias.name })
.from(categorias)
.where(
type
? and(eq(categorias.userId, userId), eq(categorias.type, type))
: eq(categorias.userId, userId),
)
.orderBy(categorias.name);
return query;
}
export async function fetchContasForSelect(
userId: string,
): Promise<SelectOption[]> {
const items = await db
.select({ id: contas.id, name: contas.name })
.from(contas)
.where(and(eq(contas.userId, userId), eq(contas.status, "ativo")))
.orderBy(contas.name);
return items;
}
export async function fetchCartoesForSelect(
userId: string,
): Promise<(SelectOption & { lastDigits?: string })[]> {
const items = await db
.select({ id: cartoes.id, name: cartoes.name })
.from(cartoes)
.where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")))
.orderBy(cartoes.name);
return items;
}
export async function fetchAppLogoMap(
userId: string,
): Promise<Record<string, string>> {
const [userCartoes, userContas] = await Promise.all([
db
.select({ name: cartoes.name, logo: cartoes.logo })
.from(cartoes)
.where(eq(cartoes.userId, userId)),
db
.select({ name: contas.name, logo: contas.logo })
.from(contas)
.where(eq(contas.userId, userId)),
]);
const logoMap: Record<string, string> = {};
for (const item of [...userCartoes, ...userContas]) {
if (item.logo) {
logoMap[item.name.toLowerCase()] = item.logo;
}
}
return logoMap;
}
export async function fetchPendingInboxCount(userId: string): Promise<number> {
const items = await db
.select({ id: preLancamentos.id })
.from(preLancamentos)
.where(
and(
eq(preLancamentos.userId, userId),
eq(preLancamentos.status, "pending"),
),
);
return items.length;
}
/**
* Fetch all data needed for the LancamentoDialog in inbox context
*/
export async function fetchInboxDialogData(userId: string): Promise<{
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
}> {
const filterSources = await fetchLancamentoFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
// Fetch recent establishments (same approach as getRecentEstablishmentsAction)
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const recentEstablishments = await db
.select({ name: lancamentos.name })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.purchaseDate, threeMonthsAgo),
),
)
.orderBy(desc(lancamentos.purchaseDate));
// Remove duplicates and filter empty names
const filteredNames: string[] = recentEstablishments
.map((r: { name: string }) => r.name)
.filter(
(name: string | null): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"),
);
const estabelecimentos = Array.from<string>(new Set(filteredNames)).slice(
0,
100,
);
return {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
};
}

View File

@@ -1,33 +0,0 @@
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</Card>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,34 +0,0 @@
import { InboxPage } from "@/components/pre-lancamentos/inbox-page";
import { getUserId } from "@/lib/auth/server";
import { fetchAppLogoMap, fetchInboxDialogData, fetchInboxItems } from "./data";
export default async function Page() {
const userId = await getUserId();
const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] =
await Promise.all([
fetchInboxItems(userId, "pending"),
fetchInboxItems(userId, "processed"),
fetchInboxItems(userId, "discarded"),
fetchInboxDialogData(userId),
fetchAppLogoMap(userId),
]);
return (
<main className="flex flex-col items-start gap-6">
<InboxPage
pendingItems={pendingItems}
processedItems={processedItems}
discardedItems={discardedItems}
pagadorOptions={dialogData.pagadorOptions}
splitPagadorOptions={dialogData.splitPagadorOptions}
defaultPagadorId={dialogData.defaultPagadorId}
contaOptions={dialogData.contaOptions}
cartaoOptions={dialogData.cartaoOptions}
categoriaOptions={dialogData.categoriaOptions}
estabelecimentos={dialogData.estabelecimentos}
appLogoMap={appLogoMap}
/>
</main>
);
}

View File

@@ -1,14 +0,0 @@
import { InstallmentAnalysisPage } from "@/components/dashboard/installment-analysis/installment-analysis-page";
import { getUser } from "@/lib/auth/server";
import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis";
export default async function Page() {
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);
return (
<main className="flex flex-col gap-4 pb-8">
<InstallmentAnalysisPage data={data} />
</main>
);
}

View File

@@ -1,12 +0,0 @@
import { asc, eq } from "drizzle-orm";
import { type Categoria, categorias } from "@/db/schema";
import { db } from "@/lib/db";
export async function fetchUserCategories(
userId: string,
): Promise<Categoria[]> {
return db.query.categorias.findMany({
where: eq(categorias.userId, userId),
orderBy: [asc(categorias.name)],
});
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -16,7 +16,7 @@
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
"hooks": "@/lib/hooks"
},
"registries": {
"@coss": "https://coss.com/ui/r/{name}.json",

View File

@@ -1,136 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client";
export function DeleteAccountForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmation, setConfirmation] = useState("");
const handleDelete = () => {
startTransition(async () => {
const result = await deleteAccountAction({
confirmation,
});
if (result.success) {
toast.success(result.message);
// Fazer logout e redirecionar para página de login
await authClient.signOut();
router.push("/");
} else {
toast.error(result.error);
}
});
};
const handleOpenModal = () => {
setConfirmation("");
setIsModalOpen(true);
};
const handleCloseModal = () => {
if (isPending) return;
setConfirmation("");
setIsModalOpen(false);
};
return (
<>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(e) => {
if (isPending) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (isPending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Você tem certeza?</DialogTitle>
<DialogDescription>
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
sua conta e remover seus dados de nossos servidores.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="confirmation">
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={isPending}
placeholder="DELETAR"
autoComplete="off"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending || confirmation !== "DELETAR"}
>
{isPending ? "Deletando..." : "Deletar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,15 +0,0 @@
import { cn } from "@/lib/utils/ui";
interface AuthHeaderProps {
title: string;
}
export function AuthHeader({ title }: AuthHeaderProps) {
return (
<div className={cn("flex flex-col gap-1.5")}>
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
</div>
);
}

View File

@@ -1,34 +0,0 @@
import MagnetLines from "../magnet-lines";
function AuthSidebar() {
return (
<div className="relative hidden flex-col overflow-hidden bg-welcome-banner text-welcome-banner-foreground md:flex">
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={10}
columns={16}
containerSize="120%"
lineColor="currentColor"
lineWidth="0.35vmin"
lineHeight="5vmin"
baseAngle={-4}
className="text-welcome-banner-foreground"
/>
</div>
<div className="relative flex flex-1 flex-col justify-between p-8">
<div className="space-y-4">
<h2 className="text-3xl font-semibold leading-tight">
Controle suas finanças com clareza e foco diário.
</h2>
<p className="text-sm opacity-90">
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia.
</p>
</div>
</div>
</div>
);
}
export default AuthSidebar;

View File

@@ -1,291 +0,0 @@
"use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
import { cn } from "@/lib/utils/ui";
import { Logo } from "../logo";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button";
interface PasswordValidation {
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
}
function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-success" : "text-muted-foreground",
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
}
type DivProps = React.ComponentProps<"div">;
export function SignupForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [fullname, setFullname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const passwordValidation = validatePassword(password);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!passwordValidation.isValid) {
setError("A senha não atende aos requisitos de segurança.");
return;
}
await authClient.signUp.email(
{
email,
password,
name: fullname,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Conta criada com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Criar sua conta" />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
<Input
id="name"
type="text"
placeholder="Digite seu nome"
autoComplete="name"
required
value={fullname}
onChange={(e) => setFullname(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Input
id="password"
type="password"
required
autoComplete="new-password"
placeholder="Crie uma senha forte"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={
!!error ||
(password.length > 0 && !passwordValidation.isValid)
}
maxLength={23}
/>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</Field>
<Field>
<Button
type="submit"
disabled={
loadingEmail ||
loadingGoogle ||
(password.length > 0 && !passwordValidation.isValid)
}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Criar conta"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Continuar com Google"
/>
</Field>
<FieldDescription className="text-center">
tem uma conta?{" "}
<a href="/login" className="underline underline-offset-4">
Entrar
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}

View File

@@ -1,128 +0,0 @@
"use client";
import { RiMoreLine } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
import { getCategoryIconOptions } from "@/lib/categorias/icons";
import { cn } from "@/lib/utils/ui";
import { CategoryIcon } from "./category-icon";
import { TypeSelectContent } from "./category-select-items";
import type { CategoryFormValues } from "./types";
interface CategoryFormFieldsProps {
values: CategoryFormValues;
onChange: (field: keyof CategoryFormValues, value: string) => void;
}
export function CategoryFormFields({
values,
onChange,
}: CategoryFormFieldsProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const iconOptions = getCategoryIconOptions();
const handleIconSelect = (icon: string) => {
onChange("icon", icon);
setPopoverOpen(false);
};
return (
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="category-name">Nome</Label>
<Input
id="category-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Alimentação"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="category-type">Tipo da categoria</Label>
<Select
value={values.type}
onValueChange={(value) => onChange("type", value)}
>
<SelectTrigger id="category-type" className="w-full">
<SelectValue placeholder="Selecione o tipo">
{values.type && (
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Ícone</Label>
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
{values.icon ? (
<CategoryIcon name={values.icon} className="size-7" />
) : (
<RiMoreLine className="size-6 text-muted-foreground" />
)}
</div>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="flex-1">
Selecionar ícone
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-3" align="start">
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
{iconOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleIconSelect(option.value)}
className={cn(
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
values.icon === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-primary",
)}
title={option.label}
>
<CategoryIcon name={option.value} className="size-6" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-muted-foreground">
Escolha um ícone que represente melhor esta categoria.
</p>
</div>
</div>
);
}

View File

@@ -1,28 +0,0 @@
"use client";
import type { RemixiconComponentType } from "@remixicon/react";
import * as RemixIcons from "@remixicon/react";
import { cn } from "@/lib/utils/ui";
const ICONS = RemixIcons as Record<string, RemixiconComponentType | undefined>;
const FALLBACK_ICON = ICONS.RiPriceTag3Line;
interface CategoryIconProps {
name?: string | null;
className?: string;
}
export function CategoryIcon({ name, className }: CategoryIconProps) {
const IconComponent =
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
if (!IconComponent) {
return (
<span className={cn("text-xs text-muted-foreground", className)}>
{name ?? "Categoria"}
</span>
);
}
return <IconComponent className={cn("size-5", className)} aria-hidden />;
}

View File

@@ -1,217 +0,0 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import { type ReactNode, useMemo } from "react";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
type DetailValue = string | number | ReactNode;
type AccountStatementCardProps = {
accountName: string;
accountType: string;
status: string;
periodLabel: string;
currentBalance: number;
openingBalance: number;
totalIncomes: number;
totalExpenses: number;
logo?: string | null;
actions?: React.ReactNode;
};
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getAccountStatusBadgeVariant = (
status: string,
): "success" | "secondary" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") {
return "success";
}
return "outline";
};
export function AccountStatementCard({
accountName,
accountType,
status,
periodLabel,
currentBalance,
openingBalance,
totalIncomes,
totalExpenses,
logo,
actions,
}: AccountStatementCardProps) {
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo da conta ${accountName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{accountName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Extrato de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Composição do Saldo */}
<div className="space-y-3">
<DetailItem
label="Saldo no início do período"
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Entradas"
value={
<span className="font-medium text-success">
{formatCurrency(totalIncomes)}
</span>
}
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
/>
<DetailItem
label="Saídas"
value={
<span className="font-medium text-destructive">
{formatCurrency(totalExpenses)}
</span>
}
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
/>
<DetailItem
label="Resultado do período"
value={
<MoneyValues
amount={totalIncomes - totalExpenses}
className={cn(
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-success"
: "text-destructive",
)}
/>
}
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
/>
</div>
{/* Saldo Atual - Destaque Principal */}
<DetailItem
label="Saldo ao final do período"
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
/>
</div>
{/* Informações da Conta */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
<DetailItem
label="Tipo da conta"
value={accountType}
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
/>
<DetailItem
label="Status da conta"
value={
<div className="flex items-center">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-xs"
>
{status}
</Badge>
</div>
}
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
/>
</div>
</CardContent>
</Card>
);
}
function DetailItem({
label,
value,
className,
tooltip,
}: {
label: string;
value: DetailValue;
className?: string;
tooltip?: string;
}) {
return (
<div className={cn("space-y-1", className)}>
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
{label}
{tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs text-xs"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : null}
</span>
<div className="text-base text-foreground">{value}</div>
</div>
);
}

View File

@@ -1,388 +0,0 @@
"use client";
import {
RiBarcodeFill,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter as ModalFooter,
} from "@/components/ui/dialog";
import type { DashboardBoleto } from "@/lib/dashboard/boletos";
import { cn } from "@/lib/utils/ui";
import { Badge } from "../ui/badge";
import { WidgetEmptyState } from "../widget-empty-state";
type BoletosWidgetProps = {
boletos: DashboardBoleto[];
};
type ModalState = "idle" | "processing" | "success";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDateLabel = (value: string | null, prefix?: string) => {
if (!value) {
return null;
}
const [year, month, day] = value.split("-").map((part) => Number(part));
if (!year || !month || !day) {
return null;
}
const formatted = DATE_FORMATTER.format(
new Date(Date.UTC(year, month - 1, day)),
);
return prefix ? `${prefix} ${formatted}` : formatted;
};
const buildStatusLabel = (boleto: DashboardBoleto) => {
if (boleto.isSettled) {
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
}
return buildDateLabel(boleto.dueDate, "Vence em");
};
const getTodayDateString = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
export function BoletosWidget({ boletos }: BoletosWidgetProps) {
const router = useRouter();
const [items, setItems] = useState(boletos);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalState, setModalState] = useState<ModalState>("idle");
const [isPending, startTransition] = useTransition();
useEffect(() => {
setItems(boletos);
}, [boletos]);
const selectedBoleto = useMemo(
() => items.find((boleto) => boleto.id === selectedId) ?? null,
[items, selectedId],
);
const isProcessing = modalState === "processing" || isPending;
const selectedBoletoDueLabel = selectedBoleto
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
: null;
const handleOpenModal = (boletoId: string) => {
setSelectedId(boletoId);
setModalState("idle");
setIsModalOpen(true);
};
const resetModalState = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const handleConfirmPayment = () => {
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
return;
}
setModalState("processing");
startTransition(async () => {
const result = await toggleLancamentoSettlementAction({
id: selectedBoleto.id,
value: true,
});
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
setItems((previous) =>
previous.map((boleto) =>
boleto.id === selectedBoleto.id
? {
...boleto,
isSettled: true,
boletoPaymentDate: getTodayDateString(),
}
: boleto,
),
);
toast.success(result.message);
router.refresh();
setModalState("success");
});
};
const getStatusBadgeVariant = (status: string): "success" | "info" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "pendente") {
return "info";
}
return "success";
};
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui."
/>
) : (
<ul className="flex flex-col">
{items.map((boleto) => {
const statusLabel = buildStatusLabel(boleto);
const isOverdue = (() => {
if (boleto.isSettled || !boleto.dueDate) return false;
const [y, m, d] = boleto.dueDate.split("-").map(Number);
if (!y || !m || !d) return false;
return new Date(Date.UTC(y, m - 1, d)) < new Date();
})();
return (
<li
key={boleto.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3 py-2">
<EstabelecimentoLogo name={boleto.name} size={37} />
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{boleto.name}
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"rounded-full py-0.5",
boleto.isSettled && "text-success",
)}
>
{statusLabel}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={boleto.amount} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={boleto.isSettled}
onClick={() => handleOpenModal(boleto.id)}
>
{boleto.isSettled ? (
<span className="flex items-center gap-1 text-success">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">Pagar</span>
</span>
) : (
"Pagar"
)}
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
if (isProcessing) {
return;
}
resetModalState();
return;
}
setIsModalOpen(true);
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
return;
}
resetModalState();
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento registrado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status do boleto para pago. Em instantes ele
aparecerá como baixado no histórico.
</DialogDescription>
</div>
<ModalFooter className="sm:justify-center">
<Button
type="button"
onClick={resetModalState}
className="sm:w-auto"
>
Fechar
</Button>
</ModalFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
<DialogDescription>
Confirme os dados para registrar o pagamento. Você poderá
editar o lançamento depois, se necessário.
</DialogDescription>
</DialogHeader>
{selectedBoleto ? (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiBarcodeFill className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Boleto
</p>
<p className="text-lg font-bold text-foreground">
{selectedBoleto.name}
</p>
</div>
</div>
{selectedBoletoDueLabel ? (
<div className="text-right">
<p className="text-sm text-muted-foreground">
{selectedBoletoDueLabel}
</p>
</div>
) : null}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor do Boleto
</span>
</div>
<MoneyValues
amount={selectedBoleto.amount}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getStatusBadgeVariant(
selectedBoleto.isSettled ? "Pago" : "Pendente",
)}
>
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
</Badge>
</div>
</div>
</div>
) : null}
<ModalFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={resetModalState}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmPayment}
disabled={
isProcessing || !selectedBoleto || selectedBoleto.isSettled
}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</ModalFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,467 +0,0 @@
"use client";
import {
RiArrowDownSLine,
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
type CategoryHistoryWidgetProps = {
data: CategoryHistoryData;
};
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
const CHART_COLORS = CATEGORY_COLORS;
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
const isFirstRender = useRef(true);
// Load from sessionStorage on mount and save on changes
useEffect(() => {
setIsClient(true);
// Only load from storage on first render
if (isFirstRender.current) {
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (_e) {
// Invalid JSON, ignore
}
}
isFirstRender.current = false;
} else {
// Save to storage on subsequent changes
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories),
);
}
}, [selectedCategories, data.allCategories]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {
return selectedCategories
.map((id, index) => {
const cat = data.categories.find((c) => c.id === id);
if (!cat) return null;
return {
...cat,
color: CHART_COLORS[index % CHART_COLORS.length],
};
})
.filter(Boolean) as Array<{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}>;
}, [data.categories, selectedCategories]);
// Filter chart data to include only selected categories
const filteredChartData = useMemo(() => {
if (filteredCategories.length === 0) {
return data.chartData.map((item) => ({ month: item.month }));
}
return data.chartData.map((item) => {
const filtered: Record<string, number | string> = { month: item.month };
filteredCategories.forEach((category) => {
filtered[category.name] = item[category.name] || 0;
});
return filtered;
});
}, [data.chartData, filteredCategories]);
// Build chart config dynamically from filtered categories
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
filteredCategories.forEach((category) => {
config[category.name] = {
label: category.name,
color: category.color,
};
});
return config;
}, [filteredCategories]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatCurrencyCompact = (value: number) => {
if (value >= 1000) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: "compact",
}).format(value);
}
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const handleAddCategory = (categoryId: string) => {
if (
categoryId &&
!selectedCategories.includes(categoryId) &&
selectedCategories.length < 5
) {
setSelectedCategories([...selectedCategories, categoryId]);
setOpen(false);
}
};
const handleRemoveCategory = (categoryId: string) => {
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
};
const handleClearAll = () => {
setSelectedCategories([]);
};
const availableCategories = useMemo(() => {
return data.allCategories.filter(
(cat) => !selectedCategories.includes(cat.id),
);
}, [data.allCategories, selectedCategories]);
const selectedCategoryDetails = useMemo(() => {
return selectedCategories
.map((id) => data.allCategories.find((cat) => cat.id === id))
.filter(Boolean);
}, [selectedCategories, data.allCategories]);
const isEmpty = filteredCategories.length === 0;
// Group available categories by type
const { despesaCategories, receitaCategories } = useMemo(() => {
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
const receita = availableCategories.filter((cat) => cat.type === "receita");
return { despesaCategories: despesa, receitaCategories: receita };
}, [availableCategories]);
if (!isClient) {
return null;
}
return (
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => {
if (!category) return null;
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const colorIndex = selectedCategories.indexOf(category.id);
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return (
<div
key={category.id}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
style={{ borderColor: color }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<div
className="size-3 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-foreground">{category.name}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveCategory(category.id)}
>
<RiCloseLine className="size-3" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{selectedCategories.length}/5 selecionadas
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</div>
)}
{selectedCategories.length < 5 && availableCategories.length > 0 && (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between hover:scale-none"
>
Selecionar categorias
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command>
<CommandInput placeholder="Pesquisar categoria..." />
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
{despesaCategories.length > 0 && (
<CommandGroup heading="Despesas">
{despesaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-destructive" />
) : (
<div className="size-3 rounded-sm bg-destructive" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{receitaCategories.length > 0 && (
<CommandGroup heading="Receitas">
{receitaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-success" />
) : (
<div className="size-3 rounded-sm bg-success" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{isEmpty ? (
<div className="h-[450px] flex items-center justify-center">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[450px] w-full">
<AreaChart
data={filteredChartData}
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
>
<defs>
{filteredCategories.map((category) => (
<linearGradient
key={`gradient-${category.id}`}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={category.color}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={category.color}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={formatCurrencyCompact}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
// Sort payload by value (descending)
const sortedPayload = [...payload].sort(
(a, b) => (b.value as number) - (a.value as number),
);
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{payload[0].payload.month}
</div>
<div className="grid gap-1.5">
{sortedPayload
.filter((entry) => (entry.value as number) > 0)
.map((entry) => {
const config =
chartConfig[
entry.dataKey as keyof typeof chartConfig
];
const value = entry.value as number;
return (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{config?.label}
</span>
</div>
<span className="text-xs font-medium tabular-nums">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
}}
/>
{filteredCategories.map((category) => (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={category.color}
strokeWidth={1}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 5,
fill: category.color,
stroke: "hsl(var(--background))",
strokeWidth: 2,
}}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,79 +0,0 @@
"use client";
import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card";
type DashboardWelcomeProps = {
name?: string | null;
disableMagnetlines?: boolean;
};
const capitalizeFirstLetter = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value;
const formatCurrentDate = (date = new Date()) => {
const formatted = new Intl.DateTimeFormat("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
hour12: false,
timeZone: "America/Sao_Paulo",
}).format(date);
return capitalizeFirstLetter(formatted);
};
const getGreeting = () => {
const now = new Date();
// Get hour in Brasilia timezone
const brasiliaHour = new Intl.DateTimeFormat("pt-BR", {
hour: "numeric",
hour12: false,
timeZone: "America/Sao_Paulo",
}).format(now);
const hour = parseInt(brasiliaHour, 10);
if (hour >= 5 && hour < 12) {
return "Bom dia";
} else if (hour >= 12 && hour < 18) {
return "Boa tarde";
} else {
return "Boa noite";
}
};
export function DashboardWelcome({
name,
disableMagnetlines = false,
}: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
return (
<Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={8}
columns={16}
containerSize="100%"
lineColor="currentColor"
lineWidth="0.4vmin"
lineHeight="5vmin"
baseAngle={0}
className="text-welcome-banner-foreground"
disabled={disableMagnetlines}
/>
</div>
<div className="relative tracking-tight text-welcome-banner-foreground">
<h1 className="text-xl">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
</div>
</Card>
);
}

View File

@@ -1,328 +0,0 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetWithChartProps = {
data: ExpensesByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function ExpensesByCategoryWidgetWithChart({
data,
period,
}: ExpensesByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return config;
}, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0,
);
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig.outros?.color,
});
}
return top7Data;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
colorIndex={index}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
despesa total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-destructive"
: hasDecrease
? "text-success"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpSFill className="size-3" />}
{hasDecrease && <RiArrowDownSFill className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget && category.budgetUsedPercentage !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded ? "text-destructive" : "text-info"
}`}
/>
<span
className={
budgetExceeded ? "text-destructive" : "text-info"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite - excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,146 +0,0 @@
"use client";
import { RiFundsLine, RiPencilLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
import { WidgetEmptyState } from "../widget-empty-state";
type GoalsProgressWidgetProps = {
data: GoalsProgressData;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const formatPercentage = (value: number, withSign = false) =>
`${new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
...(withSign ? { signDisplay: "always" as const } : {}),
}).format(value)}%`;
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
const categories = useMemo<BudgetCategory[]>(
() =>
data.categories.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
})),
[data.categories],
);
const defaultPeriod = data.items[0]?.period ?? "";
const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => {
setSelectedBudget({
id: item.id,
amount: item.budgetAmount,
spent: item.spentAmount,
period: item.period,
createdAt: item.createdAt,
category: item.categoryId
? {
id: item.categoryId,
name: item.categoryName,
icon: item.categoryIcon,
}
: null,
});
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedBudget(null);
}
}, []);
if (data.items.length === 0) {
return (
<WidgetEmptyState
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
title="Nenhum orçamento para o período"
description="Cadastre orçamentos para acompanhar o progresso das metas."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col">
{data.items.map((item, index) => {
const statusColor =
item.status === "exceeded" ? "text-destructive" : "";
const progressValue = clamp(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
return (
<li
key={item.id}
className="border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge
icon={item.categoryIcon}
name={item.categoryName}
colorIndex={index}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "}
<MoneyValues amount={item.budgetAmount} />
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`text-xs font-medium ${statusColor}`}>
{formatPercentage(percentageDelta, true)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleEdit(item)}
aria-label={`Editar orçamento de ${item.categoryName}`}
>
<RiPencilLine className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-1.5 ml-11">
<Progress value={progressValue} />
</div>
</li>
);
})}
</ul>
<BudgetDialog
mode="update"
budget={selectedBudget ?? undefined}
categories={categories}
defaultPeriod={defaultPeriod}
open={editOpen && !!selectedBudget}
onOpenChange={handleEditOpenChange}
/>
</div>
);
}

View File

@@ -1,331 +0,0 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
type IncomeByCategoryWidgetWithChartProps = {
data: IncomeByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(1)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function IncomeByCategoryWidgetWithChart({
data,
period,
}: IncomeByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return config;
}, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0,
);
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig.outros?.color,
});
}
return top7Data;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma receita encontrada"
description="Quando houver receitas registradas, elas aparecerão aqui."
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
colorIndex={index}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
receita total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-success"
: hasDecrease
? "text-destructive"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpSFill className="size-3" />}
{hasDecrease && <RiArrowDownSFill className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetAmount !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded ? "text-destructive" : "text-info"
}`}
/>
<span
className={
budgetExceeded ? "text-destructive" : "text-info"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)} -
excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)}
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,191 +0,0 @@
import { RiNumbersLine } from "@remixicon/react";
import Image from "next/image";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
import {
calculateLastInstallmentDate,
formatLastInstallmentDate,
} from "@/lib/installments/utils";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type InstallmentExpensesWidgetProps = {
data: InstallmentExpensesData;
};
const buildCompactInstallmentLabel = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
};
const isLastInstallment = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return false;
return currentInstallment === installmentCount && installmentCount > 1;
};
const calculateRemainingInstallments = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return 0;
return Math.max(0, installmentCount - currentInstallment);
};
const calculateRemainingAmount = (
amount: number,
currentInstallment: number | null,
installmentCount: number | null,
) => {
const remaining = calculateRemainingInstallments(
currentInstallment,
installmentCount,
);
return amount * remaining;
};
const formatEndDate = (
period: string,
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return null;
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount,
);
return formatLastInstallmentDate(lastDate);
};
const buildProgress = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100),
);
};
export function InstallmentExpensesWidget({
data,
}: InstallmentExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/>
);
}
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
const compactLabel = buildCompactInstallmentLabel(
expense.currentInstallment,
expense.installmentCount,
);
const isLast = isLastInstallment(
expense.currentInstallment,
expense.installmentCount,
);
const remainingInstallments = calculateRemainingInstallments(
expense.currentInstallment,
expense.installmentCount,
);
const remainingAmount = calculateRemainingAmount(
expense.amount,
expense.currentInstallment,
expense.installmentCount,
);
const endDate = formatEndDate(
expense.period,
expense.currentInstallment,
expense.installmentCount,
);
const progress = buildProgress(
expense.currentInstallment,
expense.installmentCount,
);
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
{compactLabel && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
{compactLabel}
{isLast && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icones/party.svg"
alt="Última parcela"
width={14}
height={14}
className="h-3.5 w-3.5"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Última parcela!
</TooltipContent>
</Tooltip>
)}
</span>
)}
</div>
<MoneyValues amount={expense.amount} className="shrink-0" />
</div>
<p className="text-xs text-muted-foreground ">
{endDate && `Termina em ${endDate}`}
{" | Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
/>{" "}
({remainingInstallments})
</p>
<Progress value={progress} className="h-2 mt-1" />
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

View File

@@ -1,584 +0,0 @@
"use client";
import {
RiBillLine,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiExternalLinkLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import MoneyValues from "@/components/money-values";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter as ModalFooter,
} from "@/components/ui/dialog";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Badge } from "../ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
import { WidgetEmptyState } from "../widget-empty-state";
type InvoicesWidgetProps = {
invoices: DashboardInvoice[];
};
type ModalState = "idle" | "processing" | "success";
const DUE_DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const resolveLogoPath = (logo: string | null) => {
if (!logo) {
return null;
}
if (/^(https?:\/\/|data:)/.test(logo)) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
};
const parseDueDate = (period: string, dueDay: string) => {
const [yearStr, monthStr] = period.split("-");
const dayNumber = Number.parseInt(dueDay, 10);
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (
Number.isNaN(dayNumber) ||
Number.isNaN(year) ||
Number.isNaN(month) ||
period.length !== 7
) {
return {
label: `Vence dia ${dueDay}`,
date: null,
};
}
const date = new Date(Date.UTC(year, month - 1, dayNumber));
return {
label: `Vence em ${DUE_DATE_FORMATTER.format(date)}`,
date,
};
};
const formatPaymentDate = (value: string | null) => {
if (!value) {
return null;
}
const [yearStr, monthStr, dayStr] = value.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const day = Number.parseInt(dayStr ?? "", 10);
if (
Number.isNaN(year) ||
Number.isNaN(month) ||
Number.isNaN(day) ||
yearStr?.length !== 4 ||
monthStr?.length !== 2 ||
dayStr?.length !== 2
) {
return null;
}
const date = new Date(Date.UTC(year, month - 1, day));
return {
label: `Pago em ${DUE_DATE_FORMATTER.format(date)}`,
};
};
const getTodayDateString = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatSharePercentage = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return "0%";
}
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
return `${value.toLocaleString("pt-BR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})}%`;
};
const getShareLabel = (amount: number, total: number) => {
if (total <= 0) {
return "0% do total";
}
const percentage = (amount / total) * 100;
return `${formatSharePercentage(percentage)} do total`;
};
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [items, setItems] = useState(invoices);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [modalState, setModalState] = useState<ModalState>("idle");
useEffect(() => {
setItems(invoices);
}, [invoices]);
const selectedInvoice = useMemo(
() => items.find((invoice) => invoice.id === selectedId) ?? null,
[items, selectedId],
);
const selectedLogo = useMemo(
() => (selectedInvoice ? resolveLogoPath(selectedInvoice.logo) : null),
[selectedInvoice],
);
const selectedPaymentInfo = useMemo(
() => (selectedInvoice ? formatPaymentDate(selectedInvoice.paidAt) : null),
[selectedInvoice],
);
const handleOpenModal = (invoiceId: string) => {
setSelectedId(invoiceId);
setModalState("idle");
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setModalState("idle");
setSelectedId(null);
};
const handleConfirmPayment = () => {
if (!selectedInvoice) {
return;
}
setModalState("processing");
startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({
cartaoId: selectedInvoice.cardId,
period: selectedInvoice.period,
status: INVOICE_PAYMENT_STATUS.PAID,
});
if (result.success) {
toast.success(result.message);
setItems((previous) =>
previous.map((invoice) =>
invoice.id === selectedInvoice.id
? {
...invoice,
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
paidAt: getTodayDateString(),
}
: invoice,
),
);
setModalState("success");
router.refresh();
return;
}
toast.error(result.error);
setModalState("idle");
});
};
const getStatusBadgeVariant = (status: string): "success" | "info" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "em aberto") {
return "info";
}
return "success";
};
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBillLine className="size-6 text-muted-foreground" />}
title="Nenhuma fatura para o período selecionado"
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{items.map((invoice) => {
const logo = resolveLogoPath(invoice.logo);
const initials = buildInitials(invoice.cardName);
const dueInfo = parseDueDate(invoice.period, invoice.dueDay);
const isPaid =
invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue =
!isPaid && dueInfo.date !== null && dueInfo.date < new Date();
const paymentInfo = formatPaymentDate(invoice.paidAt);
return (
<li
key={invoice.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
<div className="flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
{logo ? (
<Image
src={logo}
alt={`Logo do cartão ${invoice.cardName}`}
width={36}
height={36}
className="h-full w-full object-contain"
/>
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
<div className="min-w-0">
{(() => {
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const linkNode = (
<Link
prefetch
href={`/cartoes/${
invoice.cardId
}/fatura?periodo=${formatPeriodForUrl(
invoice.period,
)}`}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
);
if (!hasBreakdown) {
return linkNode;
}
const totalForShare = Math.abs(invoice.totalAmount);
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
{linkNode}
</HoverCardTrigger>
<HoverCardContent
align="start"
className="w-72 space-y-3"
>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Distribuição por pagador
</p>
<ul className="space-y-2">
{breakdown.map((share, index) => (
<li
key={`${invoice.id}-${
share.pagadorId ??
share.pagadorName ??
index
}`}
className="flex items-center gap-3"
>
<Avatar className="size-9">
<AvatarImage
src={getAvatarSrc(share.pagadorAvatar)}
alt={`Avatar de ${share.pagadorName}`}
/>
<AvatarFallback>
{buildInitials(share.pagadorName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{share.pagadorName}
</p>
<p className="text-xs text-muted-foreground">
{getShareLabel(
share.amount,
totalForShare,
)}
</p>
</div>
<div className="text-sm font-semibold text-foreground">
<MoneyValues amount={share.amount} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
);
})()}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{isPaid && paymentInfo ? (
<span className="text-success">
{paymentInfo.label}
</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
disabled={isPaid}
onClick={() => handleOpenModal(invoice.id)}
variant={"link"}
className="p-0 h-auto disabled:opacity-100"
>
{isPaid ? (
<span className="text-success flex items-center gap-1">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">
Pagar
</span>
</span>
) : (
<span>Pagar</span>
)}
</Button>
</div>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
handleCloseModal();
return;
}
setIsModalOpen(true);
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (modalState === "processing") {
event.preventDefault();
return;
}
handleCloseModal();
}}
onPointerDownOutside={(event) => {
if (modalState === "processing") {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento confirmado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status da fatura. O lançamento do pagamento
aparecerá no extrato em instantes.
</DialogDescription>
</div>
<ModalFooter className="sm:justify-center">
<Button
type="button"
onClick={handleCloseModal}
className="sm:w-auto"
>
Fechar
</Button>
</ModalFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Revise os dados antes de confirmar. Vamos registrar a fatura
como paga.
</DialogDescription>
</DialogHeader>
{selectedInvoice ? (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10">
{selectedLogo ? (
<Image
src={selectedLogo}
alt={`Logo do cartão ${selectedInvoice.cardName}`}
width={40}
height={40}
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs font-semibold uppercase text-primary">
{buildInitials(selectedInvoice.cardName)}
</span>
)}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Cartão
</p>
<p className="text-lg font-bold text-foreground">
{selectedInvoice.cardName}
</p>
</div>
</div>
<div className="text-right">
{selectedInvoice.paymentStatus !==
INVOICE_PAYMENT_STATUS.PAID ? (
<p className="text-sm text-muted-foreground">
{
parseDueDate(
selectedInvoice.period,
selectedInvoice.dueDay,
).label
}
</p>
) : null}
{selectedInvoice.paymentStatus ===
INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? (
<p className="text-sm text-success">
{selectedPaymentInfo.label}
</p>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor da Fatura
</span>
</div>
<MoneyValues
amount={Math.abs(selectedInvoice.totalAmount)}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getStatusBadgeVariant(
INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus],
)}
>
{INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]}
</Badge>
</div>
</div>
</div>
) : null}
<ModalFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={modalState === "processing"}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmPayment}
disabled={modalState === "processing" || isPending}
className="relative"
>
{modalState === "processing" || isPending ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</ModalFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,154 +0,0 @@
"use client";
import { RiFileList2Line, RiPencilLine, RiTodoLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import type { Note } from "@/components/anotacoes/types";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import type { DashboardNote } from "@/lib/dashboard/notes";
import { Badge } from "../ui/badge";
import { WidgetEmptyState } from "../widget-empty-state";
type NotesWidgetProps = {
notes: DashboardNote[];
};
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDisplayTitle = (value: string) => {
const trimmed = value.trim();
return trimmed.length ? trimmed : "Anotação sem título";
};
const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
id: note.id,
title: note.title,
description: note.description,
type: note.type,
tasks: note.tasks,
arquivada: note.arquivada,
createdAt: note.createdAt,
});
const getTasksSummary = (note: DashboardNote) => {
if (note.type !== "tarefa") {
return "Nota";
}
const tasks = note.tasks ?? [];
const completed = tasks.filter((task) => task.completed).length;
return `${completed}/${tasks.length} concluídas`;
};
export function NotesWidget({ notes }: NotesWidgetProps) {
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]);
const handleOpenEdit = useCallback((note: Note) => {
setNoteToEdit(note);
setIsEditOpen(true);
}, []);
const handleOpenDetails = useCallback((note: Note) => {
setNoteDetails(note);
setIsDetailsOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setIsEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
}, []);
const handleDetailsOpenChange = useCallback((open: boolean) => {
setIsDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
}, []);
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{mappedNotes.length === 0 ? (
<WidgetEmptyState
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
title="Nenhuma anotação ativa"
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
/>
) : (
<ul className="flex flex-col">
{mappedNotes.map((note) => (
<li
key={note.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{buildDisplayTitle(note.title)}
</p>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{getTasksSummary(note)}
</Badge>
<p className="truncate text-[11px] text-muted-foreground">
{DATE_FORMATTER.format(new Date(note.createdAt))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenEdit(note)}
aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
>
<RiPencilLine className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
note.title,
)}`}
>
<RiFileList2Line className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={isEditOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={isDetailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
</>
);
}

View File

@@ -1,88 +0,0 @@
import {
RiCheckLine,
RiLoader2Fill,
RiRefreshLine,
RiSlideshowLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import MoneyValues from "@/components/money-values";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentConditionsWidgetProps = {
data: PaymentConditionsData;
};
const CONDITION_ICON_CLASSES =
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
const CONDITION_ICONS: Record<string, ReactNode> = {
"À vista": <RiCheckLine className="size-5" aria-hidden />,
Parcelado: <RiLoader2Fill className="size-5" aria-hidden />,
Recorrente: <RiRefreshLine className="size-5" aria-hidden />,
};
const formatPercentage = (value: number) =>
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
export function PaymentConditionsWidget({
data,
}: PaymentConditionsWidgetProps) {
if (data.conditions.length === 0) {
return (
<WidgetEmptyState
icon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="As distribuições por condição aparecerão conforme novos lançamentos."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.conditions.map((condition) => {
const Icon =
CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
const percentageLabel = formatPercentage(condition.percentage);
return (
<li
key={condition.condition}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={CONDITION_ICON_CLASSES}>{Icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{condition.condition}
</p>
<MoneyValues amount={condition.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{condition.transactions}{" "}
{condition.transactions === 1
? "lançamento"
: "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="mt-1">
<Progress value={condition.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,87 +0,0 @@
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
};
const ICON_WRAPPER_CLASS =
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
const formatPercentage = (value: number) =>
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
const resolveIcon = (paymentMethod: string | null | undefined) => {
if (!paymentMethod) {
return <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
}
const icon = getPaymentMethodIcon(paymentMethod);
if (icon) {
return icon;
}
return <RiBankCard2Line className="size-5" aria-hidden />;
};
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
if (data.methods.length === 0) {
return (
<WidgetEmptyState
icon={
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.methods.map((method) => {
const icon = resolveIcon(method.paymentMethod);
const percentageLabel = formatPercentage(method.percentage);
return (
<li
key={method.paymentMethod}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={ICON_WRAPPER_CLASS}>{icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{method.paymentMethod}
</p>
<MoneyValues amount={method.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{method.transactions}{" "}
{method.transactions === 1 ? "lançamento" : "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="mt-1">
<Progress value={method.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import {
RiCheckboxCircleLine,
RiHourglass2Line,
RiWallet3Line,
} from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
import { Progress } from "../ui/progress";
type PaymentStatusWidgetProps = {
data: PaymentStatusData;
};
type CategorySectionProps = {
title: string;
total: number;
confirmed: number;
pending: number;
};
function CategorySection({
title,
total,
confirmed,
pending,
}: CategorySectionProps) {
// Usa valores absolutos para calcular percentual corretamente
const absTotal = Math.abs(total);
const absConfirmed = Math.abs(confirmed);
const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
<MoneyValues
amount={total}
className="text-sm font-medium tabular-nums"
/>
</div>
{/* Barra de progresso */}
<Progress value={confirmedPercentage} className="h-2" />
{/* Status de confirmados e pendentes */}
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-1.5">
<RiCheckboxCircleLine className="size-3 shrink-0 text-success" />
<MoneyValues amount={confirmed} className="tabular-nums" />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
<div className="flex items-center gap-1.5">
<RiHourglass2Line className="size-3 shrink-0 text-warning" />
<MoneyValues amount={pending} className="tabular-nums" />
<span className="text-xs text-muted-foreground">pendentes</span>
</div>
</div>
</div>
);
}
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
title="Nenhum valor a receber ou pagar no período"
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-6 px-0">
<CategorySection
title="A Receber"
total={data.income.total}
confirmed={data.income.confirmed}
pending={data.income.pending}
/>
{/* Linha divisória pontilhada */}
<div className="border-t border-dashed" />
<CategorySection
title="A Pagar"
total={data.expenses.total}
confirmed={data.expenses.confirmed}
pending={data.expenses.pending}
/>
</CardContent>
);
}

View File

@@ -1,66 +0,0 @@
import { RiRefreshLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
import { WidgetEmptyState } from "../widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
};
const formatOccurrences = (value: number | null) => {
if (!value) {
return "Recorrência contínua";
}
return `${value} recorrências`;
};
export function RecurringExpensesWidget({
data,
}: RecurringExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente"
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
/>
);
}
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<EstabelecimentoLogo name={expense.name} size={37} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<MoneyValues amount={expense.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
{expense.paymentMethod}
</span>
<span>{formatOccurrences(expense.recurrenceCount)}</span>
</div>
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

View File

@@ -1,122 +0,0 @@
import {
RiArrowDownLine,
RiArrowDownSFill,
RiArrowUpLine,
RiArrowUpSFill,
RiCashLine,
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import {
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import MoneyValues from "../money-values";
type SectionCardsProps = {
metrics: DashboardCardMetrics;
};
type Trend = "up" | "down" | "flat";
const TREND_THRESHOLD = 0.005;
const CARDS = [
{
label: "Receitas",
key: "receitas",
icon: RiArrowUpLine,
invertTrend: false,
},
{
label: "Despesas",
key: "despesas",
icon: RiArrowDownLine,
invertTrend: true,
},
{
label: "Balanço",
key: "balanco",
icon: RiIncreaseDecreaseLine,
invertTrend: false,
},
{ label: "Previsto", key: "previsto", icon: RiCashLine, invertTrend: false },
] as const;
const TREND_ICONS = {
up: RiArrowUpSFill,
down: RiArrowDownSFill,
flat: RiSubtractLine,
} as const;
const getTrend = (current: number, previous: number): Trend => {
const diff = current - previous;
if (diff > TREND_THRESHOLD) return "up";
if (diff < -TREND_THRESHOLD) return "down";
return "flat";
};
const getPercentChange = (current: number, previous: number): string => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return "0%";
return "—";
}
const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000
? `${change > 0 ? "+" : ""}${change.toFixed(1)}%`
: "—";
};
const getTrendColor = (trend: Trend, invertTrend: boolean): string => {
if (trend === "flat") return "";
const isPositive = invertTrend ? trend === "down" : trend === "up";
return isPositive
? "text-success border-success"
: "text-destructive border-destructive";
};
export function SectionCards({ metrics }: SectionCardsProps) {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
const trendColor = getTrendColor(trend, invertTrend);
return (
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4 text-primary" />
{label}
</CardTitle>
<MoneyValues className="text-2xl" amount={metric.current} />
<CardAction>
<div className={`flex items-center text-xs ${trendColor}`}>
<TrendIcon size={16} />
{getPercentChange(metric.current, metric.previous)}
</div>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>
</Card>
);
})}
</div>
);
}

View File

@@ -1,11 +0,0 @@
type DotIconProps = {
color: string;
};
export default function DotIcon({ color }: DotIconProps) {
return (
<span>
<span className={`${color} flex size-2 rounded-full`}></span>
</span>
);
}

View File

@@ -1,361 +0,0 @@
"use client";
import { RiEditLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
updateInvoicePaymentStatusAction,
updatePaymentDateAction,
} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import DotIcon from "@/components/dot-icon";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_BADGE_VARIANT,
INVOICE_STATUS_DESCRIPTION,
INVOICE_STATUS_LABEL,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { cn } from "@/lib/utils/ui";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
type InvoiceSummaryCardProps = {
cartaoId: string;
period: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
closingDay: string;
dueDay: string;
periodLabel: string;
totalAmount: number;
limitAmount: number | null;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
logo?: string | null;
actions?: React.ReactNode;
};
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
};
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
};
const actionVariantByStatus: Record<
InvoicePaymentStatus,
"default" | "outline"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "default",
[INVOICE_PAYMENT_STATUS.PAID]: "outline",
};
const formatDay = (value: string) => value.padStart(2, "0");
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getCardStatusDotColor = (status: string | null) => {
if (!status) return "bg-gray-400";
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo" || normalizedStatus === "active") {
return "bg-success";
}
return "bg-gray-400";
};
export function InvoiceSummaryCard({
cartaoId,
period,
cardName,
cardBrand,
cardStatus,
closingDay,
dueDay,
periodLabel,
totalAmount,
limitAmount,
invoiceStatus,
paymentDate: initialPaymentDate,
logo,
actions,
}: InvoiceSummaryCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [paymentDate, setPaymentDate] = useState<Date>(
initialPaymentDate ?? new Date(),
);
// Atualizar estado quando initialPaymentDate mudar
useEffect(() => {
if (initialPaymentDate) {
setPaymentDate(initialPaymentDate);
}
}, [initialPaymentDate]);
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const brandAsset = useMemo(
() => (cardBrand ? resolveBrandAsset(cardBrand) : null),
[cardBrand],
);
const limitLabel = useMemo(() => {
if (typeof limitAmount !== "number") return "—";
return limitAmount.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
}, [limitAmount]);
const targetStatus =
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
? INVOICE_PAYMENT_STATUS.PENDING
: INVOICE_PAYMENT_STATUS.PAID;
const handleAction = () => {
startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({
cartaoId,
period,
status: targetStatus,
paymentDate:
targetStatus === INVOICE_PAYMENT_STATUS.PAID
? paymentDate.toISOString().split("T")[0]
: undefined,
});
if (result.success) {
toast.success(result.message);
router.refresh();
return;
}
toast.error(result.error);
});
};
const handleDateChange = (newDate: Date) => {
setPaymentDate(newDate);
startTransition(async () => {
const result = await updatePaymentDateAction({
cartaoId,
period,
paymentDate: newDate.toISOString().split("T")[0] ?? "",
});
if (result.success) {
toast.success(result.message);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo do cartão ${cardName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : cardBrand ? (
<span className="flex size-12 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background text-sm font-semibold text-muted-foreground">
{cardBrand}
</span>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{cardName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Fatura de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Destaque Principal */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailItem
label="Valor total"
value={
<MoneyValues
amount={totalAmount}
className="text-2xl text-foreground"
/>
}
/>
<DetailItem
label="Status da fatura"
value={
<Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
className="text-xs"
>
{INVOICE_STATUS_LABEL[invoiceStatus]}
</Badge>
}
/>
</div>
{/* Informações Gerais */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Fechamento"
value={
<span className="font-medium">Dia {formatDay(closingDay)}</span>
}
/>
<DetailItem
label="Vencimento"
value={<span className="font-medium">Dia {formatDay(dueDay)}</span>}
/>
<DetailItem
label="Bandeira"
value={
brandAsset ? (
<div className="flex items-center gap-2">
<Image
src={brandAsset}
alt={`Bandeira ${cardBrand}`}
width={32}
height={32}
className="h-5 w-auto rounded"
/>
<span className="truncate">{cardBrand}</span>
</div>
) : cardBrand ? (
<span className="truncate">{cardBrand}</span>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
<DetailItem
label="Status cartão"
value={
cardStatus ? (
<div className="flex items-center gap-1.5">
<DotIcon color={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span>
</div>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
</div>
<DetailItem
label="Limite do cartão"
value={limitLabel}
className="sm:w-1/2"
/>
{/* Ações */}
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{INVOICE_STATUS_DESCRIPTION[invoiceStatus]}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="w-full shrink-0 sm:w-auto"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && (
<EditPaymentDateDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Editar data de pagamento"
>
<RiEditLine className="size-4" />
</Button>
}
currentDate={paymentDate}
onDateChange={handleDateChange}
/>
)}
</div>
</div>
</CardContent>
</Card>
);
}
type DetailItemProps = {
label?: string;
value: React.ReactNode;
className?: string;
};
function DetailItem({ label, value, className }: DetailItemProps) {
return (
<div className={cn("space-y-1", className)}>
{label && (
<span className="block text-xs font-medium uppercase text-muted-foreground/80">
{label}
</span>
)}
<div className="text-base text-foreground">{value}</div>
</div>
);
}

View File

@@ -1,80 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { getFontVariable } from "@/public/fonts/font_index";
type FontContextValue = {
systemFont: string;
moneyFont: string;
setSystemFont: (key: string) => void;
setMoneyFont: (key: string) => void;
};
const FontContext = createContext<FontContextValue | null>(null);
export function FontProvider({
systemFont: initialSystemFont,
moneyFont: initialMoneyFont,
children,
}: {
systemFont: string;
moneyFont: string;
children: React.ReactNode;
}) {
const [systemFont, setSystemFontState] = useState(initialSystemFont);
const [moneyFont, setMoneyFontState] = useState(initialMoneyFont);
const applyFontVars = useCallback((sys: string, money: string) => {
document.documentElement.style.setProperty(
"--font-app",
getFontVariable(sys),
);
document.documentElement.style.setProperty(
"--font-money",
getFontVariable(money),
);
}, []);
useEffect(() => {
applyFontVars(systemFont, moneyFont);
}, [systemFont, moneyFont, applyFontVars]);
const setSystemFont = useCallback((key: string) => {
setSystemFontState(key);
}, []);
const setMoneyFont = useCallback((key: string) => {
setMoneyFontState(key);
}, []);
const value = useMemo(
() => ({ systemFont, moneyFont, setSystemFont, setMoneyFont }),
[systemFont, moneyFont, setSystemFont, setMoneyFont],
);
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `:root { --font-app: ${getFontVariable(initialSystemFont)}; --font-money: ${getFontVariable(initialMoneyFont)}; }`,
}}
/>
<FontContext value={value}>{children}</FontContext>
</>
);
}
export function useFont() {
const ctx = useContext(FontContext);
if (!ctx) {
throw new Error("useFont must be used within FontProvider");
}
return ctx;
}

View File

@@ -1,380 +0,0 @@
"use client";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import {
CategoriaSelectContent,
ContaCartaoSelectContent,
PagadorSelectContent,
} from "../select-items";
import type { LancamentoItem, SelectOption } from "../types";
interface BulkImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
items: LancamentoItem[];
pagadorOptions: SelectOption[];
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
defaultPagadorId?: string | null;
}
export function BulkImportDialog({
open,
onOpenChange,
items,
pagadorOptions,
contaOptions,
cartaoOptions,
categoriaOptions,
defaultPagadorId,
}: BulkImportDialogProps) {
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined,
);
const [categoriaId, setCategoriaId] = useState<string | undefined>(undefined);
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId],
);
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
const transactionTypes = new Set(items.map((item) => item.transactionType));
// Filter categories based on transaction types
const filtered = categoriaOptions.filter((option) => {
if (!option.group) return false;
return Array.from(transactionTypes).some(
(type) => option.group?.toLowerCase() === type.toLowerCase(),
);
});
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as
| "Despesa"
| "Receita"
| "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as
| "Cartão de crédito"
| "Cartão de débito"
| "Pix"
| "Dinheiro"
| "Boleto"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange],
);
const itemCount = items.length;
const hasCredit = items.some(
(item) => item.paymentMethod === "Cartão de crédito",
);
const hasNonCredit = items.some(
(item) => item.paymentMethod !== "Cartão de crédito",
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Importar Lançamentos</DialogTitle>
<DialogDescription>
Importando {itemCount}{" "}
{itemCount === 1 ? "lançamento" : "lançamentos"}. Selecione o
pagador, categoria e forma de pagamento para aplicar a todos.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="pagador">Pagador *</Label>
<Select value={pagadorId} onValueChange={setPagadorId}>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione o pagador">
{pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === pagadorId,
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="categoria">Categoria *</Label>
<Select value={categoriaId} onValueChange={setCategoriaId}>
<SelectTrigger id="categoria" className="w-full">
<SelectValue placeholder="Selecione a categoria">
{categoriaId &&
(() => {
const selectedOption = categoriaOptions.find(
(opt) => opt.value === categoriaId,
);
return selectedOption ? (
<CategoriaSelectContent
label={selectedOption.label}
icon={selectedOption.icon}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categoriaGroups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<CategoriaSelectContent
label={option.label}
icon={option.icon}
/>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
{hasNonCredit && (
<div className="space-y-2">
<Label htmlFor="conta">
Conta {hasCredit ? "(para não cartão)" : "*"}
</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione a conta">
{contaId &&
(() => {
const selectedOption = contaOptions.find(
(opt) => opt.value === contaId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasCredit && (
<div className="space-y-2">
<Label htmlFor="cartao">
Cartão {hasNonCredit ? "(para cartão de crédito)" : "*"}
</Label>
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="cartao" className="w-full">
<SelectValue placeholder="Selecione o cartão">
{cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === cartaoId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Importando..." : "Importar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,210 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
getTransactionBadgeVariant,
} from "@/lib/lancamentos/formatting-helpers";
import { parseLocalDateString } from "@/lib/utils/date";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { InstallmentTimeline } from "../shared/installment-timeline";
import type { LancamentoItem } from "../types";
interface LancamentoDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
lancamento: LancamentoItem | null;
}
export function LancamentoDetailsDialog({
open,
onOpenChange,
lancamento,
}: LancamentoDetailsDialogProps) {
if (!lancamento) return null;
const isInstallment =
lancamento.condition?.toLowerCase() === "parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount;
const valorParcela = Math.abs(lancamento.amount);
const totalParcelas = lancamento.installmentCount ?? 1;
const parcelaAtual = lancamento.currentInstallment ?? 1;
const valorTotal = isInstallment
? valorParcela * totalParcelas
: valorParcela;
const valorRestante = isInstallment
? valorParcela * (totalParcelas - parcelaAtual)
: 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl sm:border-0 sm:p-2">
<div className="gap-2 space-y-4 py-4">
<CardHeader className="flex flex-row items-start border-b sm:border-b-0">
<div>
<DialogTitle className="group flex items-center gap-2 text-lg">
#{lancamento.id}
</DialogTitle>
<CardDescription>
{formatDate(lancamento.purchaseDate)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="text-sm">
<div className="grid gap-3">
<ul className="grid gap-3">
<DetailRow label="Descrição" value={lancamento.name} />
<DetailRow
label="Período"
value={formatPeriod(lancamento.period)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(lancamento.paymentMethod)}
<span className="capitalize">
{lancamento.paymentMethod}
</span>
</span>
</li>
<DetailRow
label={lancamento.cartaoName ? "Cartão" : "Conta"}
value={lancamento.cartaoName ?? lancamento.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={lancamento.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Tipo de Transação
</span>
<span className="capitalize">
<Badge
variant={getTransactionBadgeVariant(
lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType,
)}
>
{lancamento.categoriaName === "Saldo inicial"
? "Saldo Inicial"
: lancamento.transactionType}
</Badge>
</span>
</li>
<DetailRow
label="Condição"
value={formatCondition(lancamento.condition)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span className="flex items-center gap-2 capitalize">
<span>{lancamento.pagadorName}</span>
</span>
</li>
<DetailRow
label="Status"
value={lancamento.isSettled ? "Pago" : "Pendente"}
/>
{lancamento.note && (
<DetailRow label="Notas" value={lancamento.note} />
)}
</ul>
<ul className="mb-6 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
/>
</li>
)}
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{lancamento.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${lancamento.recurrenceCount} meses`}
/>
)}
{!isInstallment && <Separator className="my-2" />}
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">Total da Compra</span>
<span className="text-lg">
{currencyFormatter.format(valorTotal)}
</span>
</li>
</ul>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button">Entendi</Button>
</DialogClose>
</DialogFooter>
</CardContent>
</div>
</DialogContent>
</Dialog>
);
}
interface DetailRowProps {
label: string;
value: string;
}
function DetailRow({ label, value }: DetailRowProps) {
return (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="capitalize">{value}</span>
</li>
);
}

View File

@@ -1,538 +0,0 @@
"use client";
import { RiAddLine } from "@remixicon/react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createLancamentoAction,
updateLancamentoAction,
} from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import {
filterSecondaryPagadorOptions,
groupAndSortCategorias,
} from "@/lib/lancamentos/categoria-helpers";
import {
applyFieldDependencies,
buildLancamentoInitialState,
deriveCreditCardPeriod,
} from "@/lib/lancamentos/form-helpers";
import { BasicFieldsSection } from "./basic-fields-section";
import { BoletoFieldsSection } from "./boleto-fields-section";
import { CategorySection } from "./category-section";
import { ConditionSection } from "./condition-section";
import type {
FormState,
LancamentoDialogProps,
} from "./lancamento-dialog-types";
import { NoteSection } from "./note-section";
import { PagadorSection } from "./pagador-section";
import { PaymentMethodSection } from "./payment-method-section";
import { SplitAndSettlementSection } from "./split-settlement-section";
export function LancamentoDialog({
mode,
trigger,
open,
onOpenChange,
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
lancamento,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
lockCartaoSelection,
lockPaymentMethod,
isImporting = false,
defaultTransactionType,
forceShowTransactionType = false,
onSuccess,
onBulkEditRequest,
}: LancamentoDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const [formState, setFormState] = useState<FormState>(() =>
buildLancamentoInitialState(lancamento, defaultPagadorId, defaultPeriod, {
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
}),
);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (dialogOpen) {
const initial = buildLancamentoInitialState(
lancamento,
defaultPagadorId,
defaultPeriod,
{
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
},
);
// Derive credit card period on open when cartaoId is pre-filled
if (
initial.paymentMethod === "Cartão de crédito" &&
initial.cartaoId &&
initial.purchaseDate
) {
const card = cartaoOptions.find(
(opt) => opt.value === initial.cartaoId,
);
if (card?.closingDay) {
initial.period = deriveCreditCardPeriod(
initial.purchaseDate,
card.closingDay,
card.dueDay,
);
}
}
setFormState(initial);
setErrorMessage(null);
}
}, [
dialogOpen,
lancamento,
defaultPagadorId,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
cartaoOptions,
]);
const primaryPagador = formState.pagadorId;
const secondaryPagadorOptions = useMemo(
() => filterSecondaryPagadorOptions(splitPagadorOptions, primaryPagador),
[splitPagadorOptions, primaryPagador],
);
const categoriaGroups = useMemo(() => {
const filtered = categoriaOptions.filter(
(option) =>
option.group?.toLowerCase() === formState.transactionType.toLowerCase(),
);
return groupAndSortCategorias(filtered);
}, [categoriaOptions, formState.transactionType]);
const totalAmount = useMemo(() => {
const parsed = Number.parseFloat(formState.amount);
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
}, [formState.amount]);
const getCardInfo = useCallback(
(cartaoId: string | undefined) => {
if (!cartaoId) return null;
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
if (!card) return null;
return {
closingDay: card.closingDay ?? null,
dueDay: card.dueDay ?? null,
};
},
[cartaoOptions],
);
const handleFieldChange = useCallback(
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
setFormState((prev) => {
const effectiveCartaoId =
key === "cartaoId" ? (value as string) : prev.cartaoId;
const cardInfo = getCardInfo(effectiveCartaoId);
const dependencies = applyFieldDependencies(key, value, prev, cardInfo);
return {
...prev,
[key]: value,
...dependencies,
};
});
},
[getCardInfo],
);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (!formState.purchaseDate) {
const message = "Informe a data da transação.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.name.trim()) {
const message = "Informe a descrição do lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.isSplit && !formState.pagadorId) {
const message =
"Selecione o pagador principal para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.isSplit && !formState.secondaryPagadorId) {
const message =
"Selecione o pagador secundário para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
const amountValue = Number(formState.amount);
if (Number.isNaN(amountValue)) {
const message = "Informe um valor válido.";
setErrorMessage(message);
toast.error(message);
return;
}
const sanitizedAmount = Math.abs(amountValue);
if (!formState.categoriaId) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.paymentMethod === "Cartão de crédito") {
if (!formState.cartaoId) {
const message = "Selecione o cartão.";
setErrorMessage(message);
toast.error(message);
return;
}
} else if (!formState.contaId) {
const message = "Selecione a conta.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(),
transactionType: formState.transactionType,
amount: sanitizedAmount,
condition: formState.condition,
paymentMethod: formState.paymentMethod,
pagadorId: formState.pagadorId,
secondaryPagadorId: formState.isSplit
? formState.secondaryPagadorId
: undefined,
isSplit: formState.isSplit,
primarySplitAmount: formState.isSplit
? Number.parseFloat(formState.primarySplitAmount) || undefined
: undefined,
secondarySplitAmount: formState.isSplit
? Number.parseFloat(formState.secondarySplitAmount) || undefined
: undefined,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
categoriaId: formState.categoriaId,
note: formState.note.trim() || undefined,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
installmentCount:
formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount)
: undefined,
recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
: undefined,
dueDate:
formState.paymentMethod === "Boleto" && formState.dueDate
? formState.dueDate
: undefined,
boletoPaymentDate:
mode === "update" &&
formState.paymentMethod === "Boleto" &&
formState.boletoPaymentDate
? formState.boletoPaymentDate
: undefined,
};
startTransition(async () => {
if (mode === "create") {
const result = await createLancamentoAction(payload);
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
return;
}
// Update mode
const hasSeriesId = Boolean(lancamento?.seriesId);
if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action
onBulkEditRequest({
id: lancamento?.id ?? "",
name: formState.name.trim(),
categoriaId: formState.categoriaId,
note: formState.note.trim() || "",
pagadorId: formState.pagadorId,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
amount: sanitizedAmount,
dueDate:
formState.paymentMethod === "Boleto"
? formState.dueDate || null
: null,
boletoPaymentDate:
mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null
: null,
});
return;
}
// Atualização normal para lançamentos únicos ou todos os campos
const result = await updateLancamentoAction({
id: lancamento?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[
formState,
mode,
lancamento?.id,
lancamento?.seriesId,
setDialogOpen,
onSuccess,
onBulkEditRequest,
],
);
const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
const isNewWithType =
mode === "create" && !lancamento && defaultTransactionType;
const title =
mode === "create"
? isImportMode
? "Importar para Minha Conta"
: isCopyMode
? "Copiar lançamento"
: isNewWithType
? defaultTransactionType === "Despesa"
? "Nova Despesa"
: "Nova Receita"
: "Novo lançamento"
: "Editar lançamento";
const description =
mode === "create"
? isImportMode
? "Importando lançamento de outro usuário. Ajuste a categoria, pagador e cartão/conta antes de salvar."
: isCopyMode
? "Os dados do lançamento foram copiados. Revise e ajuste conforme necessário antes de salvar."
: isNewWithType
? `Informe os dados abaixo para registrar ${defaultTransactionType === "Despesa" ? "uma nova despesa" : "uma nova receita"}.`
: "Informe os dados abaixo para registrar um novo lançamento."
: "Atualize as informações do lançamento selecionado.";
const submitLabel = mode === "create" ? "Salvar lançamento" : "Atualizar";
const showInstallments = formState.condition === "Parcelado";
const showRecurrence = formState.condition === "Recorrente";
const showDueDate = formState.paymentMethod === "Boleto";
const showPaymentDate = mode === "update" && showDueDate;
const showSettledToggle = formState.paymentMethod !== "Cartão de crédito";
const isUpdateMode = mode === "update";
const disablePaymentMethod = Boolean(lockPaymentMethod && mode === "create");
const disableCartaoSelect = Boolean(lockCartaoSelection && mode === "create");
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
className="space-y-3 -mx-6 max-h-[80vh] overflow-y-auto px-6 pb-1"
onSubmit={handleSubmit}
noValidate
>
<BasicFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
estabelecimentos={estabelecimentos}
/>
<CategorySection
formState={formState}
onFieldChange={handleFieldChange}
categoriaOptions={categoriaOptions}
categoriaGroups={categoriaGroups}
isUpdateMode={isUpdateMode}
hideTransactionType={
Boolean(isNewWithType) && !forceShowTransactionType
}
/>
{!isUpdateMode ? (
<SplitAndSettlementSection
formState={formState}
onFieldChange={handleFieldChange}
showSettledToggle={showSettledToggle}
/>
) : null}
<PagadorSection
formState={formState}
onFieldChange={handleFieldChange}
pagadorOptions={pagadorOptions}
secondaryPagadorOptions={secondaryPagadorOptions}
totalAmount={totalAmount}
/>
<PaymentMethodSection
formState={formState}
onFieldChange={handleFieldChange}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCartaoSelect={disableCartaoSelect}
/>
{showDueDate ? (
<BoletoFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate}
/>
) : null}
<Collapsible
defaultOpen={
formState.condition !== "À vista" || formState.note.length > 0
}
>
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
<RiAddLine className="text-primary size-4 transition-transform duration-200" />
Condições e anotações
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
{!isUpdateMode ? (
<ConditionSection
formState={formState}
onFieldChange={handleFieldChange}
showInstallments={showInstallments}
showRecurrence={showRecurrence}
/>
) : null}
<NoteSection
formState={formState}
onFieldChange={handleFieldChange}
/>
</CollapsibleContent>
</Collapsible>
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,366 +0,0 @@
"use client";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { displayPeriod } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
import {
ContaCartaoSelectContent,
PaymentMethodSelectContent,
} from "../../select-items";
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
function periodToDate(period: string): Date {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function InlinePeriodPicker({
period,
onPeriodChange,
}: {
period: string;
onPeriodChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="ml-1">
<span className="text-xs text-muted-foreground">Fatura de </span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="text-xs text-primary underline-offset-2 hover:underline cursor-pointer lowercase"
>
{displayPeriod(period)}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={periodToDate(period)}
onMonthSelect={(date) => {
onPeriodChange(dateToPeriod(date));
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
export function PaymentMethodSection({
formState,
onFieldChange,
contaOptions,
cartaoOptions,
isUpdateMode,
disablePaymentMethod,
disableCartaoSelect,
}: PaymentMethodSectionProps) {
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
const showContaSelect = [
"Pix",
"Dinheiro",
"Boleto",
"Cartão de débito",
"Pré-Pago | VR/VA",
"Transferência bancária",
].includes(formState.paymentMethod);
// Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA"
const filteredContaOptions =
formState.paymentMethod === "Pré-Pago | VR/VA"
? contaOptions.filter(
(option) => option.accountType === "Pré-Pago | VR/VA",
)
: contaOptions;
return (
<>
{!isUpdateMode ? (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-1 w-full",
isCartaoSelected || showContaSelect ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
<Select
value={formState.paymentMethod}
onValueChange={(value) => onFieldChange("paymentMethod", value)}
disabled={disablePaymentMethod}
>
<SelectTrigger
id="paymentMethod"
className="w-full"
disabled={disablePaymentMethod}
>
<SelectValue placeholder="Selecione" className="w-full">
{formState.paymentMethod && (
<PaymentMethodSelectContent
label={formState.paymentMethod}
/>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{LANCAMENTO_PAYMENT_METHODS.map((method) => (
<SelectItem key={method} value={method}>
<PaymentMethodSelectContent label={method} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isCartaoSelected ? (
<div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="cartao">Cartão</Label>
<Select
value={formState.cartaoId}
onValueChange={(value) => onFieldChange("cartaoId", value)}
disabled={disableCartaoSelect}
>
<SelectTrigger
id="cartao"
className="w-full"
disabled={disableCartaoSelect}
>
<SelectValue placeholder="Selecione">
{formState.cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === formState.cartaoId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
{formState.cartaoId ? (
<InlinePeriodPicker
period={formState.period}
onPeriodChange={(value) => onFieldChange("period", value)}
/>
) : null}
</div>
) : null}
{!isCartaoSelected && showContaSelect ? (
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="conta">Conta</Label>
<Select
value={formState.contaId}
onValueChange={(value) => onFieldChange("contaId", value)}
>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{formState.contaId &&
(() => {
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.contaId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
</div>
) : null}
{isUpdateMode ? (
<div className="flex w-full flex-col gap-2 md:flex-row">
{isCartaoSelected ? (
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="cartaoUpdate">Cartão</Label>
<Select
value={formState.cartaoId}
onValueChange={(value) => onFieldChange("cartaoId", value)}
>
<SelectTrigger id="cartaoUpdate" className="w-full">
<SelectValue placeholder="Selecione">
{formState.cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === formState.cartaoId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
{formState.cartaoId ? (
<InlinePeriodPicker
period={formState.period}
onPeriodChange={(value) => onFieldChange("period", value)}
/>
) : null}
</div>
) : null}
{!isCartaoSelected && showContaSelect ? (
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="contaUpdate">Conta</Label>
<Select
value={formState.contaId}
onValueChange={(value) => onFieldChange("contaId", value)}
>
<SelectTrigger id="contaUpdate" className="w-full">
<SelectValue placeholder="Selecione">
{formState.contaId &&
(() => {
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.contaId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
</div>
) : null}
</>
);
}

View File

@@ -1,70 +0,0 @@
import { cn } from "@/lib/utils/ui";
interface EstabelecimentoLogoProps {
name: string;
size?: number;
className?: string;
}
const COLOR_PALETTE = [
"bg-purple-400 dark:bg-purple-600",
"bg-pink-400 dark:bg-pink-600",
"bg-red-400 dark:bg-red-600",
"bg-orange-400 dark:bg-orange-600",
"bg-indigo-400 dark:bg-indigo-600",
"bg-violet-400 dark:bg-violet-600",
"bg-fuchsia-400 dark:bg-fuchsia-600",
"bg-rose-400 dark:bg-rose-600",
"bg-amber-400 dark:bg-amber-600",
"bg-emerald-400 dark:bg-emerald-600",
];
function getInitials(name: string): string {
if (!name || !name.trim()) return "?";
const words = name.trim().split(/\s+/);
if (words.length === 1) {
return words[0]?.[0]?.toUpperCase() || "?";
}
const firstInitial = words[0]?.[0]?.toUpperCase() || "";
const secondInitial = words[1]?.[0]?.toUpperCase() || "";
return `${firstInitial}${secondInitial}`;
}
function generateColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % COLOR_PALETTE.length;
return COLOR_PALETTE[index] || "bg-gray-400";
}
export function EstabelecimentoLogo({
name,
size = 32,
className,
}: EstabelecimentoLogoProps) {
const initials = getInitials(name);
const colorClass = generateColorFromName(name);
return (
<div
className={cn(
"flex items-center justify-center rounded-full text-white font-bold shrink-0",
colorClass,
className,
)}
style={{
width: size,
height: size,
fontSize: (size ?? 32) * 0.4,
}}
>
{initials}
</div>
);
}

View File

@@ -1,112 +0,0 @@
"use client";
import type React from "react";
import { type CSSProperties, useEffect, useRef } from "react";
interface MagnetLinesProps {
rows?: number;
columns?: number;
containerSize?: string;
lineColor?: string;
lineWidth?: string;
lineHeight?: string;
baseAngle?: number;
className?: string;
style?: CSSProperties;
disabled?: boolean;
}
const MagnetLines: React.FC<MagnetLinesProps> = ({
rows = 9,
columns = 9,
containerSize = "80vmin",
lineColor = "#efefef",
lineWidth = "1vmin",
lineHeight = "6vmin",
baseAngle = -10,
className = "",
style = {},
disabled = false,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (disabled) return;
const container = containerRef.current;
if (!container) return;
const items = container.querySelectorAll<HTMLSpanElement>("span");
const onPointerMove = (pointer: { x: number; y: number }) => {
items.forEach((item) => {
const rect = item.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const b = pointer.x - centerX;
const a = pointer.y - centerY;
const c = Math.sqrt(a * a + b * b) || 1;
const r =
((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1);
item.style.setProperty("--rotate", `${r}deg`);
});
};
const handlePointerMove = (e: PointerEvent) => {
onPointerMove({ x: e.x, y: e.y });
};
window.addEventListener("pointermove", handlePointerMove);
if (items.length) {
const middleIndex = Math.floor(items.length / 2);
const rect = items[middleIndex].getBoundingClientRect();
onPointerMove({ x: rect.x, y: rect.y });
}
return () => {
window.removeEventListener("pointermove", handlePointerMove);
};
}, [disabled]);
// Se magnetlines estiver desabilitado, não renderiza nada
if (disabled) {
return null;
}
const total = rows * columns;
const spans = Array.from({ length: total }, (_, i) => (
<span
key={i}
className="block origin-center"
style={{
backgroundColor: lineColor,
width: lineWidth,
height: lineHeight,
// @ts-expect-error -- CSS custom property para controlar a rotação inline
"--rotate": `${baseAngle}deg`,
transform: "rotate(var(--rotate))",
willChange: "transform",
}}
/>
));
return (
<div
ref={containerRef}
className={`grid place-items-center ${className}`}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
width: containerSize,
height: containerSize,
...style,
}}
>
{spans}
</div>
);
};
export default MagnetLines;

View File

@@ -1,90 +0,0 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import {
formatPeriod,
formatPeriodForUrl,
formatPeriodParam,
MONTH_NAMES,
parsePeriodParam,
} from "@/lib/utils/period";
const PERIOD_PARAM = "periodo";
export function useMonthPeriod() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const periodFromParams = searchParams.get(PERIOD_PARAM);
const referenceDate = useMemo(() => new Date(), []);
const defaultPeriod = useMemo(
() =>
formatPeriod(referenceDate.getFullYear(), referenceDate.getMonth() + 1),
[referenceDate],
);
const { period, monthName, year } = useMemo(
() => parsePeriodParam(periodFromParams, referenceDate),
[periodFromParams, referenceDate],
);
const defaultMonth = useMemo(
() => MONTH_NAMES[referenceDate.getMonth()] ?? "",
[referenceDate],
);
const defaultYear = useMemo(
() => referenceDate.getFullYear().toString(),
[referenceDate],
);
const buildHref = useCallback(
(targetPeriod: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
const buildHrefFromMonth = useCallback(
(month: string, nextYear: string | number) => {
const parsedYear = Number.parseInt(String(nextYear).trim(), 10);
if (Number.isNaN(parsedYear)) {
return buildHref(defaultPeriod);
}
const param = formatPeriodParam(month, parsedYear);
const parsed = parsePeriodParam(param, referenceDate);
return buildHref(parsed.period);
},
[buildHref, defaultPeriod, referenceDate],
);
const replacePeriod = useCallback(
(targetPeriod: string) => {
if (!targetPeriod) {
return;
}
router.replace(buildHref(targetPeriod), { scroll: false });
},
[buildHref, router],
);
return {
pathname,
period,
currentMonth: monthName,
currentYear: year.toString(),
defaultPeriod,
defaultMonth,
defaultYear,
buildHref,
buildHrefFromMonth,
replacePeriod,
};
}
export { PERIOD_PARAM as MONTH_PERIOD_PARAM };

View File

@@ -1,36 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import type { NavItem } from "./nav-items";
import { NavLink } from "./nav-link";
type NavDropdownProps = {
items: NavItem[];
};
export function NavDropdown({ items }: NavDropdownProps) {
return (
<ul className="grid w-48 gap-0.5 p-2">
{items.map((item) => (
<li key={item.href}>
<NavLink
href={item.href}
preservePeriod={item.preservePeriod}
className="flex items-center gap-2.5 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors"
>
<span className="text-muted-foreground shrink-0">{item.icon}</span>
<span className="flex-1">{item.label}</span>
{item.badge && item.badge > 0 ? (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4 min-w-4 ml-auto"
>
{item.badge}
</Badge>
) : null}
</NavLink>
</li>
))}
</ul>
);
}

View File

@@ -1,30 +0,0 @@
// Base para links diretos e triggers — pill arredondado
export const linkBase =
"inline-flex h-8 items-center justify-center rounded-full px-3 text-sm font-medium transition-all lowercase";
// Estado inativo: muted, hover suave sem underline
export const linkIdle =
"text-muted-foreground hover:text-foreground hover:bg-accent";
// Estado ativo: pill com cor primária
export const linkActive = "bg-primary/10 text-primary";
// Trigger do NavigationMenu — espelha linkBase + linkIdle, remove estilos padrão
export const triggerClass = [
"h-8!",
"rounded-full!",
"px-3!",
"py-0!",
"text-sm!",
"font-medium!",
"bg-transparent!",
"text-muted-foreground!",
"hover:text-foreground!",
"hover:bg-accent!",
"focus:text-foreground!",
"focus:bg-accent!",
"data-[state=open]:text-foreground!",
"data-[state=open]:bg-accent!",
"shadow-none!",
"lowercase!",
].join(" ");

View File

@@ -1,360 +0,0 @@
"use client";
import {
RiAlertFill,
RiArrowRightLine,
RiAtLine,
RiBankCardLine,
RiBarChart2Line,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiFileListLine,
RiNotification3Line,
RiTimeLine,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type {
BudgetNotification,
DashboardNotification,
} from "@/lib/dashboard/notifications";
import { cn } from "@/lib/utils/ui";
type NotificationBellProps = {
notifications: DashboardNotification[];
totalCount: number;
budgetNotifications: BudgetNotification[];
preLancamentosCount?: number;
};
const resolveLogoPath = (logo: string | null | undefined) => {
if (!logo) return null;
if (/^(https?:\/\/|data:)/.test(logo)) return logo;
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
function formatDate(dateString: string): string {
const [year, month, day] = dateString.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
timeZone: "UTC",
});
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(amount);
}
function SectionLabel({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div className="flex items-center gap-1.5 px-3 pb-1 pt-3">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs text-muted-foreground">{title}</span>
</div>
);
}
export function NotificationBell({
notifications,
totalCount,
budgetNotifications,
preLancamentosCount = 0,
}: NotificationBellProps) {
const [open, setOpen] = useState(false);
const effectiveTotalCount =
totalCount + preLancamentosCount + budgetNotifications.length;
const displayCount =
effectiveTotalCount > 99 ? "99+" : effectiveTotalCount.toString();
const hasNotifications = effectiveTotalCount > 0;
const invoiceNotifications = notifications.filter(
(n) => n.type === "invoice",
);
const boletoNotifications = notifications.filter((n) => n.type === "boleto");
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Notificações"
aria-expanded={open}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
)}
>
<RiNotification3Line
className={cn(
"size-4 transition-transform duration-200",
open ? "scale-90" : "scale-100",
)}
/>
{hasNotifications && (
<>
<span
aria-hidden
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground shadow-xs ring-2 ring-background"
>
{displayCount}
</span>
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/40" />
</>
)}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Notificações
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={12}
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
>
{/* Header */}
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
<span>Notificações</span>
{hasNotifications && (
<Badge variant="outline" className="text-[10px] font-semibold">
{effectiveTotalCount}{" "}
{effectiveTotalCount === 1 ? "item" : "itens"}
</Badge>
)}
</DropdownMenuLabel>
{!hasNotifications ? (
<div className="px-4 py-8">
<Empty>
<EmptyMedia>
<RiCheckboxCircleFill color="green" />
</EmptyMedia>
<EmptyTitle>Nenhuma notificação</EmptyTitle>
<EmptyDescription>
Você está em dia com seus pagamentos!
</EmptyDescription>
</Empty>
</div>
) : (
<div className="max-h-[460px] overflow-y-auto pb-2">
{/* Pré-lançamentos */}
{preLancamentosCount > 0 && (
<div>
<SectionLabel
icon={<RiAtLine className="size-3" />}
title="Pré-lançamentos"
/>
<Link
href="/pre-lancamentos"
onClick={() => setOpen(false)}
className="group mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-accent/60"
>
<RiAtLine className="size-6 shrink-0 text-primary" />
<p className="flex-1 text-xs leading-snug text-foreground">
{preLancamentosCount === 1
? "1 pré-lançamento aguardando revisão"
: `${preLancamentosCount} pré-lançamentos aguardando revisão`}
</p>
<RiArrowRightLine className="size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
)}
{/* Orçamentos */}
{budgetNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBarChart2Line className="size-3" />}
title="Orçamentos"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{budgetNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "exceeded" ? (
<RiAlertFill className="mt-0.5 size-6 shrink-0 text-destructive" />
) : (
<RiErrorWarningLine className="mt-0.5 size-6 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "exceeded" ? (
<>
Orçamento de <strong>{n.categoryName}</strong>{" "}
excedido {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)} (
{Math.round(n.usedPercentage)}%)
</>
) : (
<>
<strong>{n.categoryName}</strong> atingiu{" "}
<strong>{Math.round(n.usedPercentage)}%</strong> do
orçamento {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)}
</>
)}
</p>
</div>
))}
</div>
</div>
)}
{/* Cartão de Crédito */}
{invoiceNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBankCardLine className="size-3" />}
title="Cartão de Crédito"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{invoiceNotifications.map((n) => {
const logo = resolveLogoPath(n.cardLogo);
return (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{logo ? (
<Image
src={logo}
alt=""
width={24}
height={24}
className="mt-0.5 size-6 shrink-0 rounded-sm object-contain"
/>
) : n.status === "overdue" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiTimeLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "overdue" ? (
<>
A fatura de <strong>{n.name}</strong> venceu em{" "}
{formatDate(n.dueDate)}
{n.showAmount && n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}
</>
) : (
<>
A fatura de <strong>{n.name}</strong> vence em{" "}
{formatDate(n.dueDate)}
{n.showAmount && n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}
</>
)}
</p>
</div>
);
})}
</div>
</div>
)}
{/* Boletos */}
{boletoNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiFileListLine className="size-3" />}
title="Boletos"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{boletoNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
<RiAlertFill
className={cn(
"mt-0.5 size-6 shrink-0",
n.status === "overdue"
? "text-destructive"
: "text-amber-500",
)}
/>
<p className="text-xs leading-snug">
{n.status === "overdue" ? (
<>
O boleto <strong>{n.name}</strong>
{n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}{" "}
venceu em {formatDate(n.dueDate)}
</>
) : (
<>
O boleto <strong>{n.name}</strong>
{n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}{" "}
vence em {formatDate(n.dueDate)}
</>
)}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,202 +0,0 @@
"use client";
import { RiAddCircleLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
deletePagadorAction,
joinPagadorByShareCodeAction,
} from "@/app/(dashboard)/pagadores/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { PagadorCard } from "@/components/pagadores/pagador-card";
import { PagadorDialog } from "@/components/pagadores/pagador-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import type { Pagador } from "./types";
interface PagadoresPageProps {
pagadores: Pagador[];
avatarOptions: string[];
}
export function PagadoresPage({
pagadores,
avatarOptions,
}: PagadoresPageProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedPagador, setSelectedPagador] = useState<Pagador | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [pagadorToRemove, setPagadorToRemove] = useState<Pagador | null>(null);
const [shareCodeInput, setShareCodeInput] = useState("");
const [joinPending, startJoin] = useTransition();
const orderedPagadores = useMemo(
() =>
[...pagadores].sort((a, b) => {
// Admin sempre primeiro
if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) {
return -1;
}
if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) {
return 1;
}
// Se ambos têm o mesmo tipo de role, ordena por nome
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
}),
[pagadores],
);
const handleEdit = useCallback((pagador: Pagador) => {
setSelectedPagador(pagador);
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedPagador(null);
}
}, []);
const handleRemoveRequest = useCallback((pagador: Pagador) => {
if (pagador.role === PAGADOR_ROLE_ADMIN) {
toast.error("Pagadores administradores não podem ser removidos.");
return;
}
setPagadorToRemove(pagador);
setRemoveOpen(true);
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setPagadorToRemove(null);
}
}, []);
const handleRemoveConfirm = useCallback(async () => {
if (!pagadorToRemove) {
return;
}
const result = await deletePagadorAction({ id: pagadorToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [pagadorToRemove]);
const removeTitle = pagadorToRemove
? `Remover pagador "${pagadorToRemove.name}"?`
: "Remover pagador?";
const handleJoinByCode = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!shareCodeInput.trim()) {
toast.error("Informe um código válido.");
return;
}
startJoin(async () => {
const result = await joinPagadorByShareCodeAction({
code: shareCodeInput.trim(),
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setShareCodeInput("");
router.refresh();
});
},
[shareCodeInput, router],
);
return (
<>
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<PagadorDialog
mode="create"
avatarOptions={avatarOptions}
trigger={
<Button className="w-full sm:w-auto">
<RiAddCircleLine className="size-4" />
Novo pagador
</Button>
}
/>
<form
onSubmit={handleJoinByCode}
className="flex w-full flex-row items-center justify-center gap-2 sm:w-auto"
>
<Input
placeholder="Código de Compartilhamento"
value={shareCodeInput}
onChange={(event) => setShareCodeInput(event.target.value)}
disabled={joinPending}
className="w-full sm:w-56 border-dashed"
/>
<Button type="submit" disabled={joinPending}>
{joinPending ? "Adicionando..." : "Adicionar por código"}
</Button>
</form>
</div>
{orderedPagadores.length === 0 ? (
<div className="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed bg-muted/30">
<div className="max-w-sm text-center text-sm text-muted-foreground">
Cadastre seu primeiro pagador para organizar cobranças e
pagamentos recorrentes.
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{orderedPagadores.map((pagador) => (
<PagadorCard
key={pagador.id}
pagador={pagador}
onEdit={pagador.canEdit ? () => handleEdit(pagador) : undefined}
onRemove={
pagador.canEdit && pagador.role !== PAGADOR_ROLE_ADMIN
? () => handleRemoveRequest(pagador)
: undefined
}
/>
))}
</div>
)}
</div>
<PagadorDialog
mode="update"
pagador={selectedPagador ?? undefined}
avatarOptions={avatarOptions}
open={editOpen && !!selectedPagador}
onOpenChange={handleEditOpenChange}
/>
<ConfirmActionDialog
open={removeOpen && !!pagadorToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover este pagador, os registros relacionados a ele deixarão de ser associados automaticamente."
confirmLabel="Remover pagador"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
}

View File

@@ -1,444 +0,0 @@
"use client";
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
bulkDeleteInboxItemsAction,
deleteInboxItemAction,
discardInboxItemAction,
markInboxAsProcessedAction,
restoreDiscardedInboxItemAction,
} from "@/app/(dashboard)/pre-lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog";
import type { InboxItem, SelectOption } from "./types";
interface InboxPageProps {
pendingItems: InboxItem[];
processedItems: InboxItem[];
discardedItems: InboxItem[];
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
appLogoMap: Record<string, string>;
}
export function InboxPage({
pendingItems,
processedItems,
discardedItems,
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
appLogoMap,
}: InboxPageProps) {
const [processOpen, setProcessOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [itemDetails, setItemDetails] = useState<InboxItem | null>(null);
const [discardOpen, setDiscardOpen] = useState(false);
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<InboxItem | null>(null);
const [restoreOpen, setRestoreOpen] = useState(false);
const [itemToRestore, setItemToRestore] = useState<InboxItem | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [bulkDeleteStatus, setBulkDeleteStatus] = useState<
"processed" | "discarded"
>("processed");
const sortByTimestamp = useCallback(
(list: InboxItem[]) =>
[...list].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[],
);
const sortedPending = useMemo(
() => sortByTimestamp(pendingItems),
[pendingItems, sortByTimestamp],
);
const sortedProcessed = useMemo(
() => sortByTimestamp(processedItems),
[processedItems, sortByTimestamp],
);
const sortedDiscarded = useMemo(
() => sortByTimestamp(discardedItems),
[discardedItems, sortByTimestamp],
);
const handleProcessOpenChange = useCallback((open: boolean) => {
setProcessOpen(open);
if (!open) {
setItemToProcess(null);
}
}, []);
const handleDetailsOpenChange = useCallback((open: boolean) => {
setDetailsOpen(open);
if (!open) {
setItemDetails(null);
}
}, []);
const handleDiscardOpenChange = useCallback((open: boolean) => {
setDiscardOpen(open);
if (!open) {
setItemToDiscard(null);
}
}, []);
const handleProcessRequest = useCallback((item: InboxItem) => {
setItemToProcess(item);
setProcessOpen(true);
}, []);
const handleDetailsRequest = useCallback((item: InboxItem) => {
setItemDetails(item);
setDetailsOpen(true);
}, []);
const handleDiscardRequest = useCallback((item: InboxItem) => {
setItemToDiscard(item);
setDiscardOpen(true);
}, []);
const handleDiscardConfirm = useCallback(async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
inboxItemId: itemToDiscard.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [itemToDiscard]);
const handleDeleteOpenChange = useCallback((open: boolean) => {
setDeleteOpen(open);
if (!open) {
setItemToDelete(null);
}
}, []);
const handleDeleteRequest = useCallback((item: InboxItem) => {
setItemToDelete(item);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!itemToDelete) return;
const result = await deleteInboxItemAction({
inboxItemId: itemToDelete.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [itemToDelete]);
const handleRestoreOpenChange = useCallback((open: boolean) => {
setRestoreOpen(open);
if (!open) {
setItemToRestore(null);
}
}, []);
const handleRestoreRequest = useCallback((item: InboxItem) => {
setItemToRestore(item);
setRestoreOpen(true);
}, []);
const handleRestoreToPendingConfirm = useCallback(async () => {
if (!itemToRestore) return;
const result = await restoreDiscardedInboxItemAction({
inboxItemId: itemToRestore.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [itemToRestore]);
const handleBulkDeleteOpenChange = useCallback((open: boolean) => {
setBulkDeleteOpen(open);
}, []);
const handleBulkDeleteRequest = useCallback(
(status: "processed" | "discarded") => {
setBulkDeleteStatus(status);
setBulkDeleteOpen(true);
},
[],
);
const handleBulkDeleteConfirm = useCallback(async () => {
const result = await bulkDeleteInboxItemsAction({
status: bulkDeleteStatus,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [bulkDeleteStatus]);
const handleLancamentoSuccess = useCallback(async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
inboxItemId: itemToProcess.id,
});
if (result.success) {
toast.success("Notificação processada!");
} else {
toast.error(result.error);
}
}, [itemToProcess]);
// Prepare default values from inbox item
const getDateString = (
date: Date | string | null | undefined,
): string | null => {
if (!date) return null;
if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10);
};
const defaultPurchaseDate =
getDateString(itemToProcess?.notificationTimestamp) ?? null;
const defaultName = itemToProcess?.parsedName
? itemToProcess.parsedName
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase())
: null;
const defaultAmount = itemToProcess?.parsedAmount
? String(Math.abs(Number(itemToProcess.parsedAmount)))
: null;
// Match sourceAppName with a cartão to pre-fill card select
const matchedCartaoId = useMemo(() => {
const appName = itemToProcess?.sourceAppName?.toLowerCase();
if (!appName) return null;
for (const option of cartaoOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, cartaoOptions]);
const renderEmptyState = (message: string) => (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiAtLine className="size-6 text-primary" />}
title={message}
description="As notificações capturadas pelo app OpenMonetis Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
/>
</Card>
);
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
list.length === 0 ? (
renderEmptyState(
readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente",
)
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{list.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : handleProcessRequest}
onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={readonly ? handleRestoreRequest : undefined}
/>
))}
</div>
);
return (
<>
<Tabs defaultValue="pending" className="w-full">
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
<TabsTrigger
value="pending"
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Pendentes</span>
<span>({pendingItems.length})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Processados</span>
<span>({processedItems.length})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Descartados</span>
<span>({discardedItems.length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="mt-4">
{renderGrid(sortedPending)}
</TabsContent>
<TabsContent value="processed" className="mt-4">
{sortedProcessed.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
)}
{renderGrid(sortedProcessed, true)}
</TabsContent>
<TabsContent value="discarded" className="mt-4">
{sortedDiscarded.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
)}
{renderGrid(sortedDiscarded, true)}
</TabsContent>
</Tabs>
<LancamentoDialog
mode="create"
open={processOpen}
onOpenChange={handleProcessOpenChange}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
estabelecimentos={estabelecimentos}
defaultPurchaseDate={defaultPurchaseDate}
defaultName={defaultName}
defaultAmount={defaultAmount}
defaultCartaoId={matchedCartaoId}
defaultPaymentMethod={matchedCartaoId ? "Cartão de crédito" : null}
forceShowTransactionType
onSuccess={handleLancamentoSuccess}
/>
<InboxDetailsDialog
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
item={itemDetails}
/>
<ConfirmActionDialog
open={discardOpen}
onOpenChange={handleDiscardOpenChange}
title="Descartar notificação?"
description="A notificação será marcada como descartada e não aparecerá mais na lista de pendentes."
confirmLabel="Descartar"
confirmVariant="destructive"
pendingLabel="Descartando..."
onConfirm={handleDiscardConfirm}
/>
<ConfirmActionDialog
open={deleteOpen}
onOpenChange={handleDeleteOpenChange}
title="Excluir notificação?"
description="A notificação será excluída permanentemente."
confirmLabel="Excluir"
confirmVariant="destructive"
pendingLabel="Excluindo..."
onConfirm={handleDeleteConfirm}
/>
<ConfirmActionDialog
open={restoreOpen}
onOpenChange={handleRestoreOpenChange}
title="Retornar para pendentes?"
description="A notificação voltará para a lista de pendentes e poderá ser processada depois."
confirmLabel="Retornar"
pendingLabel="Retornando..."
onConfirm={handleRestoreToPendingConfirm}
/>
<ConfirmActionDialog
open={bulkDeleteOpen}
onOpenChange={handleBulkDeleteOpenChange}
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
confirmLabel="Limpar tudo"
confirmVariant="destructive"
pendingLabel="Excluindo..."
onConfirm={handleBulkDeleteConfirm}
/>
</>
);
}

View File

@@ -1,51 +0,0 @@
"use client";
import { RiFireLine, RiTrophyLine } from "@remixicon/react";
import { Card, CardContent } from "@/components/ui/card";
import type { TopEstabelecimentosData } from "@/lib/relatorios/estabelecimentos/fetch-data";
type HighlightsCardsProps = {
summary: TopEstabelecimentosData["summary"];
};
export function HighlightsCards({ summary }: HighlightsCardsProps) {
return (
<div className="grid gap-3 sm:grid-cols-2">
<Card className="bg-linear-to-br from-violet-50 to-violet-50/50 dark:from-violet-950/20 dark:to-violet-950/10">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-violet-100 dark:bg-violet-900/40">
<RiTrophyLine className="size-5 text-violet-600 dark:text-violet-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-violet-700/80 dark:text-violet-400/80 font-medium">
Mais Frequente
</p>
<p className="font-bold text-xl text-violet-900 dark:text-violet-100 truncate">
{summary.mostFrequent || "—"}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-linear-to-br from-red-50 to-rose-50/50 dark:from-red-950/20 dark:to-rose-950/10">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-red-100 dark:bg-red-900/40">
<RiFireLine className="size-5 text-red-600 dark:text-red-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-red-700/80 dark:text-red-400/80 font-medium">
Maior Gasto Total
</p>
<p className="font-bold text-xl text-red-900 dark:text-red-100 truncate">
{summary.highestSpending || "—"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,43 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton para o card de resumo da conta (AccountStatementCard)
* Reflete fielmente o layout: logo + nome + tipo + badge + métricas
*/
export function AccountStatementCardSkeleton() {
return (
<div className="rounded-2xl border p-6 space-y-6">
{/* Header com logo, nome, tipo e badge */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
{/* Logo */}
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
<div className="space-y-2">
{/* Nome da conta */}
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
{/* Tipo de conta */}
<Skeleton className="h-4 w-32 rounded-2xl bg-foreground/10" />
</div>
</div>
<div className="flex items-center gap-2">
{/* Badge de status */}
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
{/* Botão de editar */}
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Métricas em grid */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
);
}

View File

@@ -1,22 +0,0 @@
import { SectionCardsSkeleton } from "./section-cards-skeleton";
import { WidgetSkeleton } from "./widget-skeleton";
/**
* Skeleton completo para o dashboard grid
* Mantém a mesma estrutura de layout do dashboard real
*/
export function DashboardGridSkeleton() {
return (
<div className="@container/main space-y-4">
{/* Section Cards no topo */}
<SectionCardsSkeleton />
{/* Grid de widgets - mesmos breakpoints do dashboard real */}
<div className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<WidgetSkeleton key={i} />
))}
</div>
</div>
);
}

View File

@@ -1,64 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton para o card de resumo da fatura (InvoiceSummaryCard)
* Reflete fielmente o layout: logo + nome + bandeira + badges + total + limite + ações
*/
export function InvoiceSummaryCardSkeleton() {
return (
<div className="rounded-2xl border p-6 space-y-6">
{/* Header com logo, nome, bandeira e badges */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
{/* Logo do cartão */}
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
<div className="space-y-2">
{/* Nome do cartão */}
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="flex items-center gap-2">
{/* Bandeira */}
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
{/* Badge de status */}
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Botão de editar */}
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
</div>
{/* Informações da fatura */}
<div className="space-y-4 pt-4 border-t">
{/* Período e status */}
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-24 rounded-2xl bg-foreground/10" />
</div>
{/* Total da fatura */}
<div className="space-y-2">
<Skeleton className="h-4 w-28 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Limite e utilização */}
<div className="grid grid-cols-2 gap-4 pt-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-28 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-28 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Botão de ação */}
<Skeleton className="h-10 w-full rounded-2xl bg-foreground/10" />
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton fiel aos cards de métricas do dashboard (SectionCards)
* Mantém o mesmo layout de 4 colunas responsivo
*/
export function SectionCardsSkeleton() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index} className="@container/card gap-2">
<CardHeader>
<div className="space-y-3">
{/* Título com ícone */}
<div className="flex items-center gap-1">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Valor principal */}
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
{/* Badge de tendência */}
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
</div>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</CardFooter>
</Card>
))}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton fiel ao WidgetCard
* Usado enquanto widgets do dashboard estão carregando
*/
export function WidgetSkeleton() {
return (
<Card className="relative h-auto md:h-custom-height-1 md:overflow-hidden">
<CardHeader className="border-b [.border-b]:pb-2">
<div className="flex w-full items-start justify-between">
<div className="space-y-2">
{/* Title com ícone */}
<div className="flex items-center gap-1">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
</div>
{/* Subtitle */}
<Skeleton className="h-4 w-48 rounded-2xl bg-foreground/10" />
</div>
</div>
</CardHeader>
<CardContent className="max-h-[calc(var(--spacing-custom-height-1)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]">
<div className="flex flex-col gap-3 py-4">
{/* Simula 5 linhas de conteúdo */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="flex flex-1 items-center gap-3">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-24 rounded-2xl bg-foreground/10" />
</div>
</div>
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,63 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils/ui";
import DotIcon from "./dot-icon";
type TypeBadgeType =
| "receita"
| "despesa"
| "Receita"
| "Despesa"
| "Transferência"
| "transferência"
| "Saldo inicial"
| "Saldo Inicial";
interface TypeBadgeProps {
type: TypeBadgeType | string;
className?: string;
}
const TYPE_LABELS: Record<string, string> = {
receita: "Receita",
despesa: "Despesa",
Receita: "Receita",
Despesa: "Despesa",
Transferência: "Transferência",
transferência: "Transferência",
"Saldo inicial": "Saldo Inicial",
"Saldo Inicial": "Saldo Inicial",
};
export function TypeBadge({ type, className }: TypeBadgeProps) {
const normalizedType = type.toLowerCase();
const isReceita = normalizedType === "receita";
const isTransferencia = normalizedType === "transferência";
const isSaldoInicial = normalizedType === "saldo inicial";
const label = TYPE_LABELS[type] || type;
const colorClass = isTransferencia
? "text-info"
: isReceita || isSaldoInicial
? "text-success"
: "text-destructive";
const dotColor = isTransferencia
? "bg-info"
: isReceita || isSaldoInicial
? "bg-success"
: "bg-destructive";
return (
<Badge
variant={"outline"}
className={cn(
"flex items-center gap-1 px-2 text-xs",
colorClass,
className,
)}
>
<DotIcon color={dotColor} />
{label}
</Badge>
);
}

View File

@@ -1,131 +0,0 @@
"use client";
import { RiExpandDiagonalLine } from "@remixicon/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
const OVERFLOW_THRESHOLD_PX = 16;
const OVERFLOW_CHECK_DEBOUNCE_MS = 100;
type WidgetProps = {
title: string;
subtitle: string;
children: React.ReactNode;
icon: React.ReactElement;
action?: React.ReactNode;
};
export default function WidgetCard({
title,
subtitle,
icon,
children,
action,
}: WidgetProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const [hasOverflow, setHasOverflow] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const checkOverflow = useCallback(() => {
const el = contentRef.current;
if (!el) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
const hasOverflowNow =
el.scrollHeight - el.clientHeight > OVERFLOW_THRESHOLD_PX;
setHasOverflow(hasOverflowNow);
}, OVERFLOW_CHECK_DEBOUNCE_MS);
}, []);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
// Checagem inicial
checkOverflow();
// Observa apenas resize do container (suficiente para detectar overflow)
const ro = new ResizeObserver(checkOverflow);
ro.observe(el);
return () => {
ro.disconnect();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [checkOverflow]);
return (
<Card className="md:h-custom-height-1 relative h-auto md:overflow-hidden">
<CardHeader className="border-b [.border-b]:pb-2">
<div className="flex w-full items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1">
<span className="text-primary">{icon}</span>
{title}
</CardTitle>
<CardDescription className="text-muted-foreground text-sm capitalize mt-1">
{subtitle}
</CardDescription>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
</CardHeader>
<CardContent
ref={contentRef}
className="max-h-[calc(var(--spacing-custom-height-1)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]"
>
{children}
</CardContent>
{hasOverflow && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-12 pb-6">
<Button
variant="outline"
className="pointer-events-auto rounded-full text-xs dark:text-white"
onClick={() => setIsOpen(true)}
aria-label="Expandir para ver todo o conteúdo"
>
Ver tudo <RiExpandDiagonalLine size={10} aria-hidden="true" />
</Button>
</div>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-h-[85vh] w-full max-w-[calc(100%-2rem)] sm:max-w-3xl overflow-hidden p-6">
<DialogHeader className="text-left">
<DialogTitle className="flex items-center gap-2">
{icon}
<span>{title}</span>
</DialogTitle>
{subtitle ? (
<p className="text-muted-foreground text-sm">{subtitle}</p>
) : null}
</DialogHeader>
<div className="scrollbar-hide max-h-[calc(85vh-6rem)] overflow-y-auto pb-6">
{children}
</div>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./db/schema.ts",
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" DROP COLUMN "disable_magnetlines";

View File

@@ -0,0 +1 @@
CREATE INDEX "lancamentos_user_id_pagador_id_period_idx" ON "lancamentos" USING btree ("user_id","pagador_id","periodo");

View File

@@ -0,0 +1,32 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "orcamentos"
WHERE "categoria_id" IS NOT NULL
AND "periodo" IS NOT NULL
GROUP BY "user_id", "categoria_id", "periodo"
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION
'Nao foi possivel criar a unique de orcamentos: existem duplicatas por user_id, categoria_id e periodo.';
END IF;
END $$;--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "faturas"
WHERE "cartao_id" IS NOT NULL
AND "periodo" IS NOT NULL
GROUP BY "user_id", "cartao_id", "periodo"
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION
'Nao foi possivel criar a unique de faturas: existem duplicatas por user_id, cartao_id e periodo.';
END IF;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX "orcamentos_user_id_categoria_id_periodo_key" ON "orcamentos" USING btree ("user_id","categoria_id","periodo");--> statement-breakpoint
CREATE UNIQUE INDEX "faturas_user_id_cartao_id_periodo_key" ON "faturas" USING btree ("user_id","cartao_id","periodo");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "lancamentos" ADD COLUMN "ofx_fit_id" text;--> statement-breakpoint
ALTER TABLE "lancamentos" ADD COLUMN "import_batch_id" text;--> statement-breakpoint
CREATE UNIQUE INDEX "lancamentos_ofx_fit_id_user_id_idx" ON "lancamentos" USING btree ("user_id","ofx_fit_id") WHERE ofx_fit_id IS NOT NULL;

View File

@@ -0,0 +1,7 @@
CREATE TABLE "import_category_mappings" (
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"description_key" text NOT NULL,
"category_id" uuid NOT NULL REFERENCES "categorias"("id") ON DELETE CASCADE,
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("user_id", "description_key")
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,41 @@
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
}
]
}

Some files were not shown because too many files have changed in this diff Show More