51 Commits

Author SHA1 Message Date
Felipe Coutinho
94bf93194f chore: ajustes de componentes, estilos, dependências e métricas do dashboard
- dashboard: melhorias em métricas, filtros de transações e overview de período
- transactions: colunas, tabela e página com novos campos e ajustes de exibição
- ui: card, table, navigation-menu, navbar, month-picker, logo-picker, theme-toggler
- calculator: ajustes de display, keypad e estado
- calendar: melhorias de grid e day-cell
- insights: atualização de constantes
- settings: pequenos ajustes
- pnpm-lock: atualização de dependências
- pdf.worker: atualização do worker

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:53 +00:00
Felipe Coutinho
d55173e8c1 refactor(transactions): remover exports mortos dateFormatter e monthFormatter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:45 +00:00
Felipe Coutinho
4a73088c09 chore(assets): substituir dashboard-preview de webp para png
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:41 +00:00
Felipe Coutinho
eaa20448a8 chore(landing): remover seção de galeria de telas
Remove a seção "Veja o que você pode fazer" com o componente ScreenshotTabs,
as 14 imagens preview-*.webp, o link #telas do nav e o export pwaCompatList sem uso.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:37 +00:00
Felipe Coutinho
367d78d43d fix(dashboard/anotacoes): corrigir divergência de fuso no formatter de datas
Intl.DateTimeFormat sem timeZone usava o fuso do servidor (UTC) no SSR
e o fuso do browser (BRT) no cliente, causando erro de hidratação.
Ambos os formatters passam a usar timeZone: "America/Sao_Paulo" explicitamente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:29 +00:00
Felipe Coutinho
2fc6d11d78 feat(dashboard/boletos): nome do boleto como link para lançamentos do período
- nome do boleto virou link para /transactions?q=<nome>
- quando o período selecionado não é o atual, inclui ?periodo=<mes-ano> na URL
- ícone RiExternalLinkLine ao lado do nome, mesmo padrão do widget de faturas

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:23 +00:00
Felipe Coutinho
0f5c735be0 feat(dashboard): confirmação de pagamento com conta e data para faturas e boletos
- widget de faturas abre modal com seleção de conta de origem e data antes de pagar
- widget de boletos ganha a mesma paridade: modal com conta de pagamento e data
- toggleTransactionSettlementAction aceita paymentAccountId e paymentDate opcionais
- DashboardBill expõe accountId para inicializar o modal com a conta já vinculada

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:16 +00:00
Felipe Coutinho
4bea6330bf feat(faturas/extrato): ajuste de fatura, reembolso e ajuste de saldo da conta
- botão "Ajustar fatura" na página da fatura abre dialog com input do valor real
  e preview da diferença; action faz upsert/delete idempotente do lançamento de ajuste
- opção "Reembolso" no dropdown de ações de despesas à vista cria receita espelhada
  no extrato ou fatura correta, vinculada ao lançamento original
- botão "Ajustar saldo" no extrato da conta compara saldo real informado e gera
  lançamento de ajuste por (accountId, period) via upsert/delete idempotente
- constantes INVOICE_ADJUSTMENT_NAME, ACCOUNT_BALANCE_ADJUSTMENT_NAME,
  REFUND_NOTE_PREFIX e buildRefundNote() centralizadas em shared/lib/accounts/constants.ts
- extrato agora contabiliza transferências internas em Entradas e Saídas corretamente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:07 +00:00
Felipe Coutinho
8389752172 feat(cartoes): exigir limite e bloquear lançamentos acima do disponível
- campo limite passa a ser NOT NULL DEFAULT 0 no schema (migration 0029)
- validação Zod com requiredDecimalSchema garante valor positivo no formulário
- validateCardLimit() em transactions/actions/core.ts bloqueia criação e edição
  de despesas em cartão que ultrapassem o limite disponível, retornando mensagem
  com o valor exato restante
- tipos Card.limit e Card.limitAvailable deixam de ser nullable
- branch "sem limite registrado" removido de card-item.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:07:56 +00:00
Felipe Coutinho
19b5aa00ee docs(changelog): registrar vetorização dos logos em v2.4.4
Adiciona ao bloco da v2.4.4 as mudanças de logo (split em LogoIcon/
LogoText, SVGs inline, troca dos PNGs por SVGs no public/ e
rasterização em alta resolução nos PDFs) e o fix do baseUrl no
tsconfig. Também atualiza a data da release para 2026-04-27.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:13:54 +00:00
Felipe Coutinho
863ccc0fd2 refactor(exports): renderizar logos SVG em alta resolução no PDF
Atualiza loadExportLogoDataUrl para carregar SVGs e rasterizar no canvas
a 4× a resolução natural antes de retornar o data URL — preserva nitidez
quando o PDF amplia a imagem. Default do path mudou para
/images/logo_text.svg.

Os exports de categorias e lançamentos agora apontam para os arquivos
.svg em vez dos .png removidos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:11:21 +00:00
Felipe Coutinho
29d99cbedb chore(assets): trocar PNGs do logo por SVGs vetorizados
- adiciona public/images/logo_small.svg e logo_text.svg com width/height
  explícitos (necessário para naturalWidth/Height funcionar via <img>)
- remove os PNGs antigos (logo_small.png e logo_text.png)
- atualiza referência no README.md (header) para logo_small.svg

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:11:15 +00:00
Felipe Coutinho
dbeb98bbe4 refactor(logo): vetorizar e separar LogoIcon/LogoText em arquivos próprios
Substitui as PNGs raster do componente Logo por SVGs inline e quebra
em dois subcomponentes reutilizáveis:

- LogoIcon (src/shared/components/logo-icon.tsx): SVG do ícone laranja
  (viewBox 0 0 200 200), aceita SVGProps via spread
- LogoText (src/shared/components/logo-text.tsx): SVG do wordmark
  (viewBox 0 0 574.201 89.6), fill #000 + dark:invert para alternar
  preto/branco conforme o tema
- Logo (orquestrador): mantém a API atual (variants full/compact/small,
  invertTextOnDark, colorIcon, iconClassName, textClassName) e agora
  renderiza os SVGs em vez de next/image

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:11:10 +00:00
Felipe Coutinho
c0436dc2ac fix(tsconfig): remover baseUrl para evitar erro de deprecação no TS 7
A remoção de "ignoreDeprecations": "6.0" no commit anterior reabriu o
erro TS5101 sobre baseUrl. Como moduleResolution: bundler resolve os
paths relativos ao próprio tsconfig.json, baseUrl é redundante e pode
ser removido.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:58:09 +00:00
Felipe Coutinho
e1e76fadc0 chore(release): v2.4.4
Versão dedicada a remover a dependência de pgcrypto e a enxugar os
backups. CHANGELOG, badge do README e fluxo de restore atualizados;
script pnpm db:extensions removido do package.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:54 +00:00
Felipe Coutinho
9b2c15ef7d chore: ajustes de configuração diversos
- tsconfig: target ES2017 → ES2022, remove ignoreDeprecations 6.0
- gitignore: ignora pasta .codex
- next.config: remove linha em branco supérflua

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:50 +00:00
Felipe Coutinho
fbe3fceb9f chore(backup): escopar dumps aos schemas public e drizzle
Adiciona --schema=public --schema=drizzle aos pg_dump (modos remote e
docker), descartando os schemas internos do Supabase (auth, realtime,
storage, vault, graphql, etc.). Restaurações em PostgreSQL padrão
deixam de produzir os ~148 erros de role/extension does not exist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:42 +00:00
Felipe Coutinho
39f3cd8b20 feat(payers): gerar share_code na aplicação e remover pgcrypto
Move a geração do share_code do PostgreSQL para a camada de aplicação,
eliminando a dependência da extensão pgcrypto no setup do banco.

- schema: drop default substr(encode(gen_random_bytes(24), 'base64'), 1, 24)
  da coluna share_code em pagadores (continua NOT NULL)
- nova util generateShareCode() em shared/lib/payers/share-code.ts
  (server-only, usa crypto.randomBytes do Node)
- chamadas explícitas em createPayerAction, ensureDefaultPagadorForUser,
  resetUserAppData e mock-data ao inserir pagadores
- migration 0028_fancy_reaper renumerada (0027 já estava ocupado por
  arquivo órfão); journal e snapshot atualizados
- remove etapa de habilitação de pgcrypto do docker-entrypoint.sh
- remove scripts/postgres/ (init.sql e enable-extensions.ts)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:36 +00:00
Felipe Coutinho
791fec7751 chore(release): v2.4.3
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:47:05 +00:00
Felipe Coutinho
114e2b4011 chore(deps): bump dependencies e schema do biome
- @aws-sdk/client-s3 + s3-request-presigner: 3.1032 → 3.1037
- @better-auth/passkey: 1.6.5 → 1.6.9
- better-auth: 1.6.5 → 1.6.9
- @tanstack/react-query: 5.99.2 → 5.100.3
- @biomejs/biome: 2.4.12 → 2.4.13 (com $schema atualizado)
- @tailwindcss/postcss + tailwindcss: 4.2.2 → 4.2.4
- resend: 6.12.0 → 6.12.2
- knip: 6.4.1 → 6.7.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:58 +00:00
Felipe Coutinho
f15a003cef fix(docker): healthcheck usar 127.0.0.1 para hosts com IPv6 (#44)
Containers em hosts com IPv6 habilitado tentavam conectar via ::1
e falhavam por timeout antes de cair no fallback IPv4. Fixar
127.0.0.1 elimina a ambiguidade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:18 +00:00
Felipe Coutinho
7f07a9cbf6 fix(i18n): corrigir rótulos pt-br em categorias e dialog de antecipação
- updateCategoryAction: mensagem de sucesso "Category atualizada com
  sucesso." → "Categoria atualizada com sucesso."
- AnticipateInstallmentsDialog: rótulos "Period" → "Fatura" e
  "Category" → "Categoria"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:12 +00:00
Felipe Coutinho
5fa234884e style(ui): refresh em badges de tipo, radio buttons e antecipação
- TransactionTypeBadge: substitui StatusDot por ícones direcionais
  (RiArrowRightDownLine receita, RiArrowRightUpLine despesa,
  RiArrowLeftRightLine transferência), adiciona borda e shadow sutil
  e dessaturação no dark mode; rótulo "Transferência" abreviado
  para "Transf."
- RadioGroup: indicador trocado de RiCircleLine por RiCheckLine com
  fundo sólido primary no estado selecionado
- Tabela de seleção de parcelas no dialog de antecipação reduzida
  para três colunas (estabelecimento, fatura, valor); coluna de
  vencimento removida e nome do estabelecimento absorve a parcela
- Inter agora carrega explicitamente os pesos 500, 600 e 700

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:05 +00:00
Felipe Coutinho
b453b432ed perf(logos): pré-resolver mapeamentos Logo.dev no servidor
Cada EstablishmentLogo dispara um GET para /api/logo/mapping por
nome único (deduplicado pelo React Query, mas ainda N requests por
página). Em /dashboard, /transactions e /payers/[payerId] agora
fazemos uma única query SQL em batch (fetchEstablishmentLogoMap) e
semeamos o cache do React Query antes do primeiro render via novo
LogoPrefetchProvider — eliminando os requests da rede.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:45:54 +00:00
Felipe Coutinho
7f05d2a681 fix(attachments): limpar arquivos órfãos no S3 em deleções e reset
Três caminhos de deleção não chamavam o cleanup de storage, deixando
arquivos órfãos no S3:

- deleteTransactionBulkAction: deleções por escopo de série (período,
  futuras, todas) agora coletam attachments vinculados antes do delete
  e disparam cleanupAttachmentsAfterTransactionDelete
- deleteMultipleTransactionsAction: mesma correção para seleção
  múltipla de lançamentos
- resetUserAppData: reset de conta em Ajustes coleta os fileKeys
  antes de truncar e remove os objetos do S3 em paralelo

Também ajusta deleteS3Object para ignorar NoSuchKey silenciosamente,
necessário para providers S3-compatíveis como Cloudflare R2 que não
são idempotentes nessa operação.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:45:45 +00:00
Felipe Coutinho
b14f487824 feat(transactions): edição cooperativa e visibilidade de divisões
Adiciona splitGroupId para vincular as duas shares de um lançamento
dividido (schema + índice + migration 0026). Habilita:

- Edição de par dividido com escolha de escopo (apenas este lado ou
  ambos) via novo SplitPairDialog e updateTransactionSplitPairAction
- Filtro "Somente divididos" (isDivided) na tabela de lançamentos
- Visibilidade de anexos para pessoas com acesso compartilhado via
  payerShares; upload e detach em massa expandem para shares irmãs
- Cópia independente de anexos no fluxo "Importar para Minha Conta"
  (novo fileKey, novo userId, S3 CopyObject) com seção read-only
  "Anexos que serão copiados" no dialog de importação
- Ícone de clipe na tabela de lançamentos da página da pessoa via
  EXISTS em fetchPagadorLancamentos

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:45:35 +00:00
Felipe Coutinho
5b03824a72 fix(security): remover header CSP de respostas de API
CSP não tem efeito em respostas JSON e expunha domínios
internos (Umami, Supabase, logo.dev) em endpoints públicos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:44:18 +00:00
Felipe Coutinho
74dda549f5 style(format): corrigir ordenação de imports em 3 arquivos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:57:05 +00:00
Felipe Coutinho
137b63f256 style(format): corrigir formatação Biome em 5 arquivos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:54:56 +00:00
Felipe Coutinho
f747405264 feat(calendar): agrupar parcelas da mesma série em evento único
Lançamentos parcelados com o mesmo seriesId agora são consolidados em
um único evento do tipo 'installment' no calendário, exibindo 'Nx de
R$ X' em vez de repetir o mesmo item N vezes. Legenda e modal de
detalhes atualizados para refletir o novo tipo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:50:02 +00:00
Felipe Coutinho
cbc17c8513 style(notes): polimento visual nas tarefas e modal de detalhes
Ícone de tarefa concluída em card e detalhes simplificado para
RiCheckLine verde sem caixa. Checkbox no modal de edição usa bg/border
success com texto success-foreground (claro no light, escuro no dark).
Footer do modal de detalhes reordenado: Cancelar à esquerda, Alterar
primário à direita.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:23:36 +00:00
Felipe Coutinho
c41fafc319 style(assets): atualizar previews de lançamentos e pwa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:41:48 +00:00
Felipe Coutinho
0bc3f06b77 refactor(ui): renomear "Pagador/Pagadores" para "Pessoa/Pessoas" na interface
Todas as strings visíveis ao usuário (labels, títulos, toasts, mensagens
de erro, cabeçalhos de tabela, exportações) foram atualizadas. Acordos
de gênero em português corrigidos. Código, rotas (/payers) e schema do
banco (pagadores) permanecem inalterados — divergência intencional
documentada em CLAUDE.md e CHANGELOG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:29:55 +00:00
Felipe Coutinho
2f68bcf039 style(changelog): destacar resumo de versão com borda e itálico discretos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:56:52 +00:00
Felipe Coutinho
41dcd5cec9 docs(claude): exigir parágrafo humano em cada versão do CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:56:26 +00:00
Felipe Coutinho
6391f07eb6 fix(changelog): renderizar parágrafo de resumo por versão
Parser ignorava texto livre entre o cabeçalho ## [versão] e a primeira
seção ###. Adicionado campo `summary` em ChangelogVersion e captura das
linhas de texto antes da primeira seção. ChangelogTab renderiza o resumo
logo abaixo do cabeçalho, antes das entradas técnicas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:55:09 +00:00
Felipe Coutinho
ae9dd364c4 chore(release): v2.4.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:52:26 +00:00
Felipe Coutinho
e005add233 feat(logo): migrar token Logo.dev para runtime server-side
NEXT_PUBLIC_LOGO_DEV_TOKEN renomeado para LOGO_DEV_TOKEN — lido apenas
em runtime no servidor. URL construída nos endpoints /api/logo/mapping e
/api/logo/search; cliente nunca recebe o token. Novo server.ts com
isLogoDevEnabled() e buildLogoDevUrl(). LogoDevProvider (Context) propaga
flag `enabled` para Client Components. Build arg removido do Dockerfile
e do workflow docker-publish.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:52:24 +00:00
Felipe Coutinho
6d81ff8b53 style(ui): polimento visual — tema, cards, dark mode e landing page
Raio de borda global 0.625rem → 0.7rem; ajustes finos em --card e --border.
DotPattern removido do layout, tela de auth e landing page.
Account-card redesenhado (cores de saldo, tooltip de flags de exclusão).
Budget-card, card-item, calendário (day-cell, event-modal) com layout revisado.
Auth-card-shell simplificado (sem glassmorphism/blob). Landing page com
mainFeatures + extraFeatures em grid único e dark mode nos botões de CTA.
Imagens de preview da landing atualizadas. CSS --data-7..10 removidas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:52:17 +00:00
Felipe Coutinho
5d84ae928a refactor(nav): remover sidebar, unificar navegação na navbar
Componentes da sidebar lateral (app-sidebar, nav-main, nav-secondary,
nav-user, nav-link), sidebar.tsx e use-mobile.ts removidos.
Barrel exports órfãos de shared/hooks, shared/components/providers,
shared/lib/schemas e shared/lib/types também removidos.
Navbar recebe ajustes menores de markup e acessibilidade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:52:07 +00:00
Felipe Coutinho
ba05985725 refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets
Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/
foram movidos para subdiretórios temáticos (bills/, invoices/, notes/,
notifications/, overview/, payments/, goals-progress/, categories/).
~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura
baseada em widget-registry com components/widgets/. Novos componentes:
category-breakdown-chart/list, goals-progress-item, percentage-change-indicator.
Imports atualizados em fetch-dashboard-data e transaction-filters limpos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:51:56 +00:00
Felipe Coutinho
3e80d5995b chore(release): v2.4.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:44 +00:00
Felipe Coutinho
68daae7926 fix(docker): fixar PGDATA para compatibilidade com postgres:18-alpine (#41)
Container do PostgreSQL falhava ao iniciar em instalações existentes
após atualização da imagem: o entrypoint passou a recusar dados no
caminho legado. Variável PGDATA fixa o caminho e preserva dados de
quem já tinha o volume populado.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:40 +00:00
Felipe Coutinho
9413c470a8 style(ui): restaurar indentação tabs no dashboard layout
PR #42 trocou tabs por spaces no arquivo inteiro, quebrando o Biome.
Revertido pelo lint:fix para manter consistência com o resto do projeto.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:35 +00:00
Felipe Coutinho
ad1b0aa979 refactor(settings): remover tab órfã de Integrações
A tab foi introduzida no PR #42 mas não tinha TabsContent correspondente
e o value tinha typo ("intergrations") — UI vazia.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:31 +00:00
Felipe Coutinho
4d9a1c0a35 perf(db): otimizar índices — remover 7 sem uso, adicionar 17 em FKs
Baseado em análise do pg_stat_user_indexes (187 dias de estatísticas):
removidos 7 índices com 0 scans e adicionados 17 índices em foreign
keys que antes geravam sequential scans durante deletes nas tabelas pai.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:26 +00:00
Felipe Coutinho
5635705c56 feat(ui): layout animado auth, capitalização navbar (PR #42) 2026-04-16 15:19:53 +00:00
Alexsandro
4c97ed569d Merge branch 'main' into feat/fix-ui 2026-04-16 11:52:37 -03:00
Alexsandro
22a88de993 style(ui): update auth pages layout and navigation capitalization
This commit improves the visual design of the auth pages by adding a new layout wrapper with an animated blob background effect and updating the auth card shell with a glassmorphism style. It also updates the navigation items to use capitalized labels instead of lowercase for better readability.
2026-04-15 14:35:44 -03:00
Felipe Coutinho
9456aa98bc fix(ci): passar NEXT_PUBLIC_LOGO_DEV_TOKEN como build arg no Docker
NEXT_PUBLIC_* é inlined pelo Next.js em build time — a variável precisa
ser injetada via ARG no Dockerfile e build-args no workflow do CI.
Sem isso, o token fica undefined e os logos nunca são exibidos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:23 +00:00
Felipe Coutinho
21c6a8d9d0 fix(lint): corrigir schema biome.json e formatação de imports
- biome.json: bump schema 2.4.10 → 2.4.11
- establishment-logo-picker.tsx, establishment-logo.tsx, navigation-menu.tsx, logo/index.ts: organizar imports e ajustar formatação conforme Biome 2.4.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:31:46 +00:00
339 changed files with 19228 additions and 7125 deletions

View File

@@ -58,7 +58,5 @@ OPENROUTER_API_KEY=
# === Logo.dev (Opcional) === # === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev # Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
# NEXT_PUBLIC_LOGO_DEV_TOKEN — token público (aparece no frontend, ok por design) LOGO_DEV_TOKEN=
# LOGO_DEV_SECRET_KEY — chave secreta (apenas server-side, nunca expor)
NEXT_PUBLIC_LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY= LOGO_DEV_SECRET_KEY=

1
.gitignore vendored
View File

@@ -106,6 +106,7 @@ docker-compose.override.yml
.cursor/ .cursor/
QWEN.md QWEN.md
AGENTS.md AGENTS.md
.codex
# === Backups locais === # === Backups locais ===
/backup/ /backup/

View File

@@ -5,7 +5,169 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [Unreleased] ## [2.5.0] - 2026-05-01
Esta versão melhora o fechamento de faturas, a correção de lançamentos já registrados e a conferência de saldos contra o extrato do banco. O novo **ajuste de fatura** fecha a conta entre o total calculado pelo sistema e o valor real cobrado pelo banco, sem exigir que o usuário reabra lançamentos individuais. A mesma ideia foi estendida para **contas correntes**: na página do extrato, ao lado de "Saldo ao final do período", o usuário informa o saldo real e o sistema cria (ou atualiza) um lançamento de ajuste no período visualizado. Também entra o fluxo de **reembolso** para despesas à vista: pelo menu de ações do lançamento, o usuário informa a data do reembolso e o sistema cria uma receita espelhada no extrato ou na fatura correta. O widget de boletos do dashboard ganhou paridade com o widget de faturas — confirmação de pagamento agora pede conta de origem e data antes de quitar o boleto. Por fim, o **limite do cartão** passou a ser obrigatório e o sistema bloqueia despesas em cartão que ultrapassem o limite disponível, retornando uma mensagem com o valor exato disponível. As operações mantêm rastro no lançamento gerado e respeitam a proteção de faturas já pagas.
### Adicionado
- Nome do boleto no widget de Boletos agora é um link para `/transactions?q=<nome>`, incluindo `?periodo=<mes-ano>` automaticamente quando o período selecionado não é o atual. Ícone `RiExternalLinkLine` ao lado do nome, igual ao padrão do widget de Faturas.
- Botão "Ajustar fatura" ao lado do valor na página da fatura.
- Dialog `AdjustInvoiceDialog` com input de valor correto e preview da diferença.
- Action `adjustInvoiceAction` que faz upsert/delete idempotente do lançamento de ajuste.
- Botão "Ajustar saldo" ao lado do valor na página do extrato da conta.
- Dialog `AdjustBalanceDialog` com input do saldo correto e preview da diferença que será lançada (receita ou despesa).
- Action `adjustAccountBalanceAction` que faz upsert/delete idempotente do lançamento de ajuste por `(accountId, period)`.
- Opção "Reembolso" no dropdown de ações de despesas à vista, posicionada após "Copiar" e antes de "Remover".
- Dialog `RefundTransactionDialog` com seleção da data do reembolso e indicação do período de destino.
- Action `refundTransactionAction` que cria uma receita de reembolso vinculada ao lançamento original.
- Constantes compartilhadas `INVOICE_ADJUSTMENT_NAME`, `ACCOUNT_BALANCE_ADJUSTMENT_NAME`, `REFUND_NOTE_PREFIX` e `buildRefundNote()` em `shared/lib/accounts/constants.ts`.
- Validação de limite de cartão: `validateCardLimit()` em `transactions/actions/core.ts` calcula o uso atual do cartão (somando lançamentos não quitados, com a mesma regra usada em `cards/queries.ts` para recorrentes) e bloqueia criação ou edição de despesa em cartão que ultrapasse o disponível, retornando "Lançamento de R$ X excede o limite disponível do cartão (R$ Y)."
- Schema reutilizável `requiredDecimalSchema(fieldName)` em `shared/lib/schemas/common.ts` — número/string positiva (`> 0`) com mensagens parametrizáveis.
### Alterado
- **Limite do cartão é obrigatório**: campo `limite` em `cartoes` ganhou `NOT NULL DEFAULT 0` no schema, validação Zod com `requiredDecimalSchema("limite")`, atributo `required` no input do formulário e checagem client-side antes do submit. Tipos `Card.limit` e `Card.limitAvailable` deixam de ser nullable; branch "Ainda não há limite registrado" foi removido de `card-item.tsx` e a derivação defensiva em `cards/[cardId]/invoice` foi simplificada.
- Migration `0029_friendly_spitfire`: preenche com `0` registros legados antes do `SET NOT NULL` para não quebrar bancos com cartões sem limite.
- Métricas principais passam a tratar reembolsos como abatimento de despesa, não como receita comum.
- Cards de receitas/despesas, série histórica do dashboard e resumo do extrato agora preservam o efeito líquido do reembolso no balanço sem inflar entradas e saídas.
- Pagamento de fatura agora abre confirmação com conta de origem selecionável; por padrão vem a conta vinculada ao cartão, mas o usuário pode escolher outra conta antes de confirmar.
- Widget de faturas no dashboard ganhou a mesma confirmação: o modal "Confirmar pagamento" agora pede conta de origem e data antes de marcar a fatura como paga, alinhando o comportamento ao da página de fatura.
- Widget de boletos no dashboard ganhou a mesma paridade: o modal "Confirmar pagamento" passou a oferecer seleção de **conta de pagamento** e **data do pagamento**, com mesma estrutura de cards de detalhes, métricas, separator e formulário condicional do widget de faturas.
- `toggleTransactionSettlementAction` agora aceita `paymentAccountId` e `paymentDate` opcionais para boletos — quando informados, atualiza a `accountId` do lançamento e usa a data escolhida em `boletoPaymentDate` (em vez da data atual).
- `DashboardBill` passa a expor `accountId` para que o dialog inicialize a conta com o valor já vinculado ao boleto.
- Widget "Lançamentos por Categorias" agora ignora a categoria "Transferência interna" — transferências entre contas próprias deixam de poluir o ranking de categorias.
### Corrigido
- Erro de hidratação no widget de Anotações: `Intl.DateTimeFormat` sem `timeZone` usava o fuso do servidor (UTC) no SSR e o fuso do browser (BRT) no cliente, resultando em datas divergentes. Ambos os formatters passam a usar `timeZone: "America/Sao_Paulo"` explicitamente.
- Extrato da conta agora contabiliza transferências internas nos cards de **Entradas** e **Saídas**: transferência recebida soma em Entradas, transferência enviada soma em Saídas. Antes o saldo final refletia o movimento mas os cards permaneciam zerados, gerando inconsistência visível na tela (issue #47).
### Removido
- Seção "Veja o que você pode fazer" (galeria de screenshots com abas) da landing page, junto com o componente `ScreenshotTabs`, as 14 imagens `preview-*.webp`, o bloco `screenshots` em `images.ts`, o link `#telas` do nav e o export `pwaCompatList` sem uso.
- Exports mortos `dateFormatter` e `monthFormatter` de `features/transactions/formatting-helpers.ts`.
## [2.4.4] - 2026-04-27
Esta versão remove a dependência da extensão `pgcrypto` do PostgreSQL para a geração do `share_code` em pagadores. O default a nível de banco (`gen_random_bytes`) foi removido — agora a aplicação gera o código sempre via `crypto.randomBytes` do Node.js, num utilitário compartilhado. A consequência prática é que o setup inicial fica mais simples: não há mais script de habilitação de extensão, nem etapa extra no primeiro `db:push`, e bancos restaurados de dumps externos não precisam ter `pgcrypto` instalada. O script de backup também foi enxugado para gerar dumps focados nos schemas relevantes (`public` e `drizzle`), descartando os schemas internos do Supabase e eliminando os ~148 erros de restore em PostgreSQL padrão. Por fim, os logos da marca (ícone laranja e wordmark) foram vetorizados: as PNGs antigas foram substituídas por SVGs inline em componentes próprios e por arquivos `.svg` no `public/`, escalando perfeitamente em qualquer tamanho — inclusive nos PDFs exportados, que agora rasterizam o SVG em alta resolução.
### Alterado
- Schema: coluna `share_code` em `pagadores` perdeu o default `substr(encode(gen_random_bytes(24), 'base64'), 1, 24)` — campo continua `NOT NULL` e a aplicação passa a fornecer o valor explicitamente em todas as inserções
- Pagadores: nova função utilitária `generateShareCode()` em `src/shared/lib/payers/share-code.ts` (server-only) — usa `crypto.randomBytes(18).toString("base64url").slice(0, 24)`
- Pagadores: `createPayerAction`, `ensureDefaultPagadorForUser`, `resetUserAppData` (settings) e `mock-data.ts` agora chamam `generateShareCode()` ao inserir um pagador
- Backup: `scripts/backup.sh` agora dumpa apenas os schemas `public` e `drizzle` — schemas internos do Supabase (`auth`, `realtime`, `storage`, `vault`, `graphql`, `graphql_public`, `extensions`, `pgbouncer`) e suas extensions/roles deixam de poluir os dumps. Restaurações em PostgreSQL padrão passam a executar sem os ~148 erros de `role/extension does not exist`
- Logo: `Logo` foi quebrado em três arquivos — `src/shared/components/logo.tsx` (orquestrador), `logo-icon.tsx` (ícone laranja em SVG inline, viewBox `0 0 200 200`) e `logo-text.tsx` (wordmark em SVG inline, viewBox `0 0 574.201 89.6`). API pública (`variant`, `invertTextOnDark`, `colorIcon`, `iconClassName`, `textClassName`) preservada
- Assets: `public/images/logo_small.png` e `logo_text.png` substituídos por `logo_small.svg` e `logo_text.svg` (com `width`/`height` explícitos para compatibilidade com `<img>` em canvas)
- Exports: `loadExportLogoDataUrl` agora carrega SVG e rasteriza no canvas a 4× a resolução natural antes de gerar o data URL — mantém nitidez quando o PDF amplia a imagem
### Removido
- Pasta `scripts/postgres/` (continha `init.sql` e `enable-extensions.ts`)
- Script `pnpm db:extensions` no `package.json`
- Referências ao `pnpm db:extensions` no README
- `public/images/logo_small.png` e `public/images/logo_text.png` (substituídos pelos `.svg`)
### Corrigido
- Migrations: conflito de numeração resolvido — `0027_fancy_reaper` renomeado para `0028_fancy_reaper` (o número 0027 já estava ocupado pelo arquivo órfão `0027_glorious_mindworm`); journal e snapshot atualizados
- TS: removido `baseUrl` do `tsconfig.json` para evitar erro `TS5101` (deprecação no TS 7) — `moduleResolution: bundler` resolve os `paths` relativos ao próprio `tsconfig`, dispensando `baseUrl`
### Documentação
- README: seção Backup atualizada — arquivos gerados agora especificam que apenas os schemas `public` e `drizzle` são dumpados
- README: seção Restore reescrita com o fluxo correto para banco Docker (`DROP SCHEMA public CASCADE` + `pg_restore --clean --if-exists --disable-triggers`)
- README: comando rápido de Docker Compose de backup/restore substituído por `pnpm backup`
- README: header passa a apontar para `logo_small.svg`
## [2.4.3] - 2026-04-25
Esta versão amplia o trabalho com lançamentos divididos: anexos passam a ser visíveis para pessoas com acesso compartilhado, a importação para conta própria copia os arquivos de forma independente e a edição ganha a opção de aplicar a alteração nos dois lados do par. Três caminhos de deleção foram corrigidos para não deixar arquivos órfãos no storage. Também traz refresh visual nos badges de tipo e radio buttons, prefetch server-side de logos para reduzir chamadas de API no dashboard, e ajustes pontuais no healthcheck do container e em rótulos da UI.
### Adicionado
- Schema: coluna `split_group_id` (uuid, nullable) em `lancamentos` com índice `(user_id, split_group_id)` — liga as shares do mesmo evento de divisão
- Split: `buildLancamentoRecords` atribui um `splitGroupId` único por cycle (parcelado, recorrente ou único) para ambas as shares
- Split: edição cooperativa via `updateTransactionSplitPairAction` — ao editar um lançamento dividido, novo dialog `SplitPairDialog` permite escolher entre aplicar somente neste lado ou nos dois lados (nome, data, categoria e demais campos compartilhados; valor e payer permanecem por share)
- Importação: "Importar para Minha Conta" agora copia os anexos do lançamento-fonte para a conta de quem está importando (novo arquivo, novo `userId`, novo `fileKey` — cópia independente via S3 CopyObject). `createSchema` ganhou campo opcional `importFromTransactionId`; helper `copyAttachmentsForImport` valida acesso à fonte via ownership direto ou `payerShares`
- Importação: dialog "Importar para Minha Conta" exibe seção read-only "Anexos que serão copiados" listando os anexos do lançamento-fonte antes da confirmação
- Filtros: nova chave `isDivided` na tabela de lançamentos — toggle "Somente divididos" no drawer de filtros mantém o estado na URL
- Performance: prefetch server-side de mapeamentos Logo.dev no `/dashboard`, `/transactions` e `/payers/[payerId]` — uma única query SQL em batch (`fetchEstablishmentLogoMap`) semeia o cache do React Query antes do primeiro render, eliminando os N requests para `/api/logo/mapping`
### Alterado
- Anexos: `fetchTransactionAttachments` e `fetchTransactionAttachmentsAction` passam a autorizar leitura por acesso à transação (direto ou via `payerShares`), permitindo que pessoas com pagador compartilhado visualizem anexos de lançamentos divididos
- Anexos: upload (`confirmAttachmentUploadAction`) e detach em massa (`detachAttachmentBulkAction`) agora expandem `transactionIds` para incluir shares irmãs via `splitGroupId` — o vínculo em `transaction_attachments` é replicado para manter simetria
- Anexos: delete/detach continuam restritos ao criador (sem alteração de escrita); dashboard (`fetchAttachmentsForPeriod`) permanece listando apenas os anexos do próprio usuário
- Migração: lançamentos divididos criados antes desta versão ficam com `split_group_id` NULL e mantêm o comportamento antigo (anexos não visíveis para a contraparte); apenas splits novos são afetados
- Storage: `deleteS3Object` passa a ignorar `NoSuchKey` silenciosamente — providers S3-compatíveis (ex.: Cloudflare R2) lançam esse erro ao deletar objeto inexistente, ao contrário do comportamento idempotente do S3 padrão
- UI/Badges: `TransactionTypeBadge` redesenhado — substitui o `StatusDot` por ícones direcionais (`RiArrowRightDownLine` receita, `RiArrowRightUpLine` despesa, `RiArrowLeftRightLine` transferência), com borda visível, shadow sutil e variantes dark mode dessaturadas; rótulo "Transferência" abreviado para "Transf."
- UI/Forms: indicador do `RadioGroup` trocado de círculo (`RiCircleLine`) por check (`RiCheckLine`) com fundo sólido `primary` no estado selecionado
- UI/Antecipação: tabela de seleção de parcelas reduzida de quatro para três colunas (estabelecimento + fatura + valor) — informações de parcela e vencimento absorvidas pela coluna do estabelecimento
- Tipografia: fonte Inter agora carrega explicitamente os pesos 500, 600 e 700 (antes derivava de 400)
- Deps: better-auth 1.6.5 → 1.6.9, @aws-sdk/client-s3 3.1032 → 3.1037, @tanstack/react-query 5.99.2 → 5.100.3, @biomejs/biome 2.4.12 → 2.4.13, tailwindcss 4.2.2 → 4.2.4, resend 6.12.0 → 6.12.2
### Corrigido
- Anexos: deleção em massa por série (`deleteTransactionBulkAction`) não chamava cleanup de storage — arquivos ficavam órfãos no S3 após apagar "este e futuros" ou "todos" de uma série parcelada/recorrente com anexo
- Anexos: deleção múltipla por seleção (`deleteMultipleTransactionsAction`) não chamava cleanup de storage — mesmo problema ao selecionar vários lançamentos com anexo e deletar em lote
- Anexos: reset de conta em Ajustes (`resetUserAppData`) não limpava o storage — todos os arquivos do usuário ficavam órfãos no S3 após a operação de zeragem
- Página da pessoa (`/payers/[payerId]`): `fetchPagadorLancamentos` agora calcula `hasAttachments` via `EXISTS`, fazendo o ícone de clipe aparecer na tabela de lançamentos (antes só aparecia em `/transactions`)
- Categorias: mensagem de sucesso ao atualizar exibia "Category atualizada com sucesso." — corrigido para "Categoria atualizada com sucesso."
- Antecipação: rótulos "Category" e "Período" no dialog corrigidos para "Categoria" e "Fatura"
- Docker: healthcheck do container `app` agora usa `127.0.0.1:3000` em vez de `localhost:3000`, evitando connection timeout em hosts com IPv6 (resolvendo [#44](https://github.com/felipegcoutinho/openmonetis/issues/44))
## [2.4.2] - 2026-04-20
Esta versão é quase toda sobre organização e polimento. O código interno do Dashboard foi reestruturado — módulos espalhados pela raiz da feature foram agrupados em subdiretórios coesos e a arquitetura de widgets foi renovada com um novo `widget-registry`. A sidebar lateral foi aposentada em favor de uma navegação concentrada na navbar. A interface passou por um refinamento visual amplo: cards redesenhados, dark mode mais consistente e efeitos decorativos removidos para uma composição mais limpa. As imagens de preview da landing page foram atualizadas. Por fim, a integração com Logo.dev ganhou uma arquitetura mais segura — o token agora é lido apenas no servidor e nunca chega ao cliente. O conceito de "Pagador" foi renomeado para "Pessoa" em toda a interface.
### Adicionado
- Dashboard: nova arquitetura de widgets com `widget-registry` — módulos reorganizados em subdiretórios (`bills/`, `invoices/`, `notes/`, `notifications/`, `overview/`, `payments/`, `goals-progress/`, `categories/`)
- Dashboard: novos componentes `category-breakdown-chart`, `category-breakdown-list`, `goals-progress-item` e `percentage-change-indicator`
- Logo.dev: `server.ts` com `isLogoDevEnabled()` e `buildLogoDevUrl()` server-side; `LogoDevProvider` propaga flag `enabled` para Client Components
- Scripts: `mockup` adicionado ao `package.json` (`tsx scripts/mock-data.ts`)
### Alterado
- Nav: sidebar lateral removida — navegação unificada na navbar
- UI/Tema: raio de borda global 0.625rem → 0.7rem; ajustes finos em `--card` e `--border` (light e dark)
- UI: `DotPattern` removido do layout dashboard, tela de autenticação e landing page
- UI: account-card redesenhado com cores de saldo (success/destructive) e tooltip para flags de exclusão
- UI: budget-card, card-item e componentes do calendário (day-cell, event-modal) com layout revisado
- UI: auth-card-shell simplificado (removido glassmorphism e blob animado)
- Landing: imagens de preview atualizadas; `mainFeatures` + `extraFeatures` unificados em grid único; dark mode nos botões de CTA
- Navbar: dark mode corrigido no navbar-shell (`dark:bg-card`, `dark:border-b-border`)
- Logo.dev: `NEXT_PUBLIC_LOGO_DEV_TOKEN` renomeado para `LOGO_DEV_TOKEN` (agora lido em runtime server-side apenas)
- UI: conceito "Pagador/Pagadores" renomeado para **"Pessoa/Pessoas"** em toda a interface — labels, títulos, toasts, mensagens de erro, cabeçalhos de tabela e exportações. Código, rotas (`/payers`) e schema do banco (`pagadores`) permanecem inalterados; a divergência entre UI e código é intencional
- Deps: next 16.2.3 → 16.2.4, better-auth 1.6.2 → 1.6.5, ai 6.0.159 → 6.0.168 e outros patches menores
- Notas/Tarefas: ícone de tarefa concluída em visualização (card e detalhes) simplificado para `RiCheckLine` verde sem caixa; checkbox no modal de edição usa fundo e borda `success` com ícone `success-foreground` (claro no light, escuro no dark)
- Notas/Detalhes: botões do footer reordenados ("Cancelar" à esquerda, "Alterar" primário à direita)
### Removido
- Nav: componentes sidebar (`app-sidebar`, `nav-main`, `nav-secondary`, `nav-user`, `nav-link`), `sidebar.tsx` e `use-mobile.ts`
- Dashboard: ~25 widgets monolíticos obsoletos (`inbox-widget`, `bills-widget`, `notes-widget`, `payers-widget`, `my-accounts-widget` etc.)
- Dashboard: arquivos dispersos na raiz da feature movidos para subdiretórios (arquivos antigos removidos)
- CSS: variáveis `--data-7` a `--data-10` removidas do tema
- CI: build arg `NEXT_PUBLIC_LOGO_DEV_TOKEN` removido do `Dockerfile` e do workflow `docker-publish.yml` — basta configurar `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` como variáveis de runtime no host (Coolify, Railway, etc.)
## [2.4.1] - 2026-04-16
### Adicionado
- UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42)
- DB: 17 índices em foreign keys — evita sequential scans em deletes nas tabelas pai. Impacto maior nas FKs de `lancamentos` (conta_id, categoria_id, antecipacao_id), onde deletes em `categorias` antes provocavam full scan na tabela de lançamentos
### Alterado
- UI/Navbar: labels capitalizados (Lançamentos, Categorias, Contas) em vez de caixa baixa — melhora legibilidade (PR #42)
### Removido
- DB: 7 índices sem uso — `tokens_api_user_id_idx`, `cartoes_user_id_status_idx`, `contas_user_id_status_idx`, `pagadores_user_id_status_idx`, `pagadores_user_id_role_idx`, `dashboard_notification_states_user_id_archived_idx`, `antecipacoes_parcelas_series_id_idx` (0 scans em 187 dias de estatísticas)
- UI/Settings: tab de Integrações órfã removida (não tinha `TabsContent` correspondente)
### Corrigido
- Docker: container do PostgreSQL falhava ao iniciar em instalações existentes após atualização da imagem `postgres:18-alpine` — entrypoint passou a recusar dados no caminho legado `/var/lib/postgresql/data`. Adicionada variável `PGDATA` no `docker-compose.yml` para fixar o caminho e preservar dados de quem já tinha o volume populado (resolve #41)
## [2.4.0] - 2026-04-13 ## [2.4.0] - 2026-04-13

View File

@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md). 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md). Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna...").
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema. 8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md. 9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
@@ -217,7 +217,9 @@ Layouts, `loading.tsx` e metadata continuam em `src/app/`.
| `contas` | `accounts` | | `contas` | `accounts` |
| `categorias` | `categories` | | `categorias` | `categories` |
| `orcamentos` | `budgets` | | `orcamentos` | `budgets` |
| `pagadores` | `payers` | | `pessoas` | `payers` |
> **Nota:** o conceito de "pagador" foi renomeado para **"pessoa"** na UI (labels, toasts, textos visíveis ao usuário). O código, rotas e schema continuam usando o termo original em inglês (`payer`, `payerId`, `adminPayerId`) e em português interno (`pagador` como variável). Não renomear esses identificadores — a divergência entre UI e código é intencional e documentada.
| `anotacoes` | `notes` | | `anotacoes` | `notes` |
| `calendario` | `calendar` | | `calendario` | `calendar` |
| `ajustes` | `settings` | | `ajustes` | `settings` |

389
DESIGN.md Normal file
View File

@@ -0,0 +1,389 @@
# Design System Inspired by OpenMonetis
## 1. Visual Theme & Atmosphere
OpenMonetis embodies a warm, approachable financial management aesthetic grounded in trust and transparency. The design system combines a rich warm-orange accent palette with a sophisticated warm-neutral foundation, creating an interface that feels both professional and inviting. The typography and spacing work together to emphasize clarity and hierarchy, supporting the open-source ethos of personal financial control. The visual atmosphere prioritizes legibility and calm navigation, with generous whitespace and deliberate color restraint—the bold orange is reserved for critical calls-to-action and highlights, while the warm grays and blacks anchor the interface with stability and focus.
**Key Characteristics**
- Warm, approachable color story with a dominant orange accent (`#FF7733`)
- Generous whitespace and breathing room between sections
- High contrast between backgrounds and text for accessibility
- Clear typographic hierarchy using Inter for all text and UI
- Minimal elevation and shadow treatment—mostly flat design
- Subtle border accents in warm grays to define surfaces
- Open-source transparency reflected in straightforward, honest design language
## 2. Color Palette & Roles
### Primary
- **Primary Accent** (`#FF7733`): Used for primary call-to-action buttons, highlights, and key interactive elements throughout the interface; draws user attention to the most important actions
- **Primary Dark** (`#443732`): Warm-brown anchor color used extensively for text, headings, and interactive elements; provides the primary text color across the system
### Interactive
- **Interactive Neutral** (`#0F0D0C`): Near-black used for primary text and strong emphasis elements; highest contrast state
- **Interactive Overlay** (`#0006`): Transparent black overlay at 24% opacity; used for hover states, modals, and depth layering
### Neutral Scale
- **Neutral 900** (`#2A2827`): Very dark warm gray; used for secondary text and disabled states
- **Neutral 800** (`#322C2A`): Dark warm gray; alternative text color for lower-emphasis content
- **Neutral 700** (`#676260`): Medium warm gray; used for tertiary text, captions, and metadata
- **Neutral 50** (`#FCF7F6`): Almost-white warm cream; primary background color for light surfaces
- **Neutral 100** (`#F8F6F4`): Very light warm gray; secondary background and subtle surface distinction
- **Neutral 200** (`#F5F2EF`): Light warm gray; tertiary background and card interiors
- **Neutral 300** (`#F0EEEC`): Pale warm gray; border and divider lines
### Surface & Borders
- **Surface** (`#FFFFFF`): Pure white; primary card and container background; high-contrast surface
- **Border Light** (`#F0EEEC`): Pale warm gray used for subtle borders between elements
- **Border Dark** (`#2A2827`): Dark warm gray; stronger borders for defined card boundaries
### Semantic / Status
- **Success** (`#0E9D6E`): Green used for positive status indicators, confirmation states, and success messages
- **Warning** (`#F7A439`): Amber used for cautionary states, warnings, and attention-drawing alerts
- **Error** (`#F53F2D`): Red-orange used for error states, destructive actions, and validation failures
- **Error Alt** (`#D40C1A`): Deep red alternative for critical errors and danger states
## 3. Typography Rules
### Font Family
**Primary:** Inter (sans-serif)
Fallback: `Inter, system-ui, -apple-system, sans-serif`
**Monospace:** ui-monospace
Fallback: `ui-monospace, 'Courier New', monospace`
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display / H1 | Inter | 60px | 600 | 60px | 0px | Page titles and hero headlines; maximum visual impact |
| Heading H2 | Inter | 36px | 600 | 40px | 0px | Section headers and major subsections |
| Heading H3 | Inter | 16px | 600 | 20px | 0px | Card titles and smaller section headers |
| Body | Inter | 20px | 400 | 28px | 0px | Descriptive text, paragraphs, and long-form content |
| Body Secondary | Inter | 16px | 400 | 24px | 0px | Standard body text, list items, and descriptions |
| Emphasis / Span | Inter | 14px | 500 | 20px | 0px | Emphasized text, labels, and badge content |
| Button | Inter | 14px | 500 | 20px | 0px | All button text; medium weight for clarity |
| Caption | Inter | 14px | 400 | 20px | 0px | Metadata, timestamps, and small supporting text |
| Code | ui-monospace | 14px | 400 | 20px | 0px | Code blocks and technical content |
### Principles
- **Contrast & Clarity:** Text color `#0F0D0C` on light backgrounds; `#FFFFFF` on dark backgrounds
- **Weight Hierarchy:** Use 600 weight for all headings; 500 for interactive/emphasized text; 400 for body
- **Scale Progression:** Sizes increase in meaningful increments (14 → 16 → 20 → 36 → 60); maintain consistent rhythm
- **Line Height:** Body text uses 1.4× line height multiplier for comfortable reading; headings use tighter ratio (1:1) for impact
- **Readability First:** Avoid line lengths over 80 characters for long-form content; increase line height on smaller screens
## 4. Component Stylings
### Buttons
#### Primary Button
- **Background:** `#FF7733`
- **Text Color:** `#FFFFFF`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 16px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `40px`
- **Box Shadow:** `none`
- **Hover State:** Darken background to `#E55F1F`; add subtle shadow `0px 2px 8px rgba(0, 0, 0, 0.12)`
- **Active State:** Darken further to `#CC5118`; increase shadow
- **Disabled State:** Background `#E8E3E0`; text color `#999890`; cursor not-allowed
#### Secondary Button
- **Background:** `#FFFFFF`
- **Text Color:** `#2A2827`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 24px`
- **Border Radius:** `9.2px`
- **Border:** `1px solid #F0EEEC`
- **Height:** `40px`
- **Box Shadow:** `0px 1px 3px rgba(0, 0, 0, 0.06)`
- **Hover State:** Background `#F8F6F4`; border color `#E8E3E0`
- **Active State:** Background `#F0EEEC`; border color `#D5CCCA`
- **Disabled State:** Background `#FAFAF8`; text color `#BFBAB7`; border color `#F0EEEC`
#### Ghost Button
- **Background:** `transparent`
- **Text Color:** `#443732`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `6px 8px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `32px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.05)`
- **Active State:** Background `rgba(68, 55, 50, 0.1)`
- **Disabled State:** Text color `#BFBAB7`; cursor not-allowed
#### Icon Button
- **Background:** `transparent`
- **Icon Color:** `#443732`
- **Size:** `32px` × `32px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Padding:** `0px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.08)`
- **Active State:** Background `rgba(68, 55, 50, 0.12)`
### Cards & Containers
#### Standard Card
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `11.2px`
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Text Color:** `#2A2827`
- **Hover State:** Border color `#E8E3E0`; box-shadow `0px 4px 12px rgba(0, 0, 0, 0.08)`
#### Card with Top Border
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `15.2px 15.2px 0px 0px` (top corners only)
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Top Border Color:** `#FF7733` (3px height implied)
#### Surface Container (Header/Nav)
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 24px`
- **Box Shadow:** `none`
- **Text Color:** `#FFFFFF`
- **Border:** `0px solid transparent`
#### Light Surface
- **Background:** `#F8F6F4`
- **Border:** `0px solid transparent`
- **Border Radius:** `11.2px`
- **Padding:** `16px`
- **Box Shadow:** `none`
### Inputs & Forms
#### Text Input
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Line Height:** `24px`
- **Placeholder Color:** `#999890`
- **Focus State:** Border color `#FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
- **Error State:** Border color `#F53F2D`; background `#FEF5F3`
- **Disabled State:** Background `#F8F6F4`; text color `#BFBAB7`; border color `#F0EEEC`
#### Select / Dropdown
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Focus State:** Border color `#FF7733`; outline `0px`
- **Hover State:** Background `#FAFAF8`
#### Checkbox & Radio
- **Size:** `20px` × `20px`
- **Border Radius:** `4px` (checkbox), `50%` (radio)
- **Border:** `2px solid #F0EEEC`
- **Background:** `#FFFFFF`
- **Checked Background:** `#FF7733`
- **Checked Border:** `2px solid #FF7733`
- **Checked Icon Color:** `#FFFFFF`
- **Focus:** Border `2px solid #FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
### Navigation
#### Primary Navigation
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 48px`
- **Display:** flex; align-items: center; gap `32px`
- **Link Color:** `#FFFFFF`
- **Link Font Size:** `16px`
- **Link Font Weight:** `400`
- **Link Hover:** Opacity `0.8`
- **Link Active:** Text decoration underline; opacity `1.0`
#### Secondary Navigation / Tabs
- **Background:** `transparent`
- **Border Bottom:** `2px solid #F0EEEC`
- **Tab Padding:** `16px 24px`
- **Tab Color:** `#676260`
- **Tab Font Size:** `16px`
- **Tab Hover:** Color `#443732`
- **Tab Active:** Color `#FF7733`; border-bottom color `#FF7733`
#### Breadcrumb Navigation
- **Font Size:** `14px`
- **Color:** `#676260`
- **Separator:** `/` with `0px 8px` margin
- **Link Color:** `#443732`
- **Link Hover:** Color `#FF7733`
- **Current (Active):** Color `#2A2827`; font-weight `500`
### Badges & Status Indicators
#### Badge Default
- **Background:** `#F8F6F4`
- **Text Color:** `#443732`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
- **Border:** `0px solid transparent`
#### Badge Success
- **Background:** `#E8F5F0`
- **Text Color:** `#0E9D6E`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
#### Badge Warning
- **Background:** `#FEF5E8`
- **Text Color:** `#F7A439`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
#### Badge Error
- **Background:** `#FEF5F3`
- **Text Color:** `#F53F2D`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
## 5. Layout Principles
### Spacing System
- **Base Unit:** `4px`
- **Scale:** `4px`, `8px`, `12px`, `16px`, `24px`, `32px`, `48px`, `56px`, `64px`, `80px`, `96px`, `128px`
**Usage Contexts:**
- **48px:** Tight spacing within compact components (icon-text pairs, inline elements)
- **1216px:** Standard padding inside cards, inputs, and buttons
- **2432px:** Section gaps, spacing between components on a page
- **4864px:** Large section separations, hero spacing
- **80128px:** Hero margins, page-level vertical rhythm
### Grid & Container
- **Max Width:** `1440px` for full-width containers
- **Content Width:** `1152px` for typical page layouts
- **Column Strategy:** 12-column grid system; gutter `24px`
- **Container Padding:** `48px` on desktop (left + right)
- **Section Pattern:** Full-width containers with internal max-width constraint
### Whitespace Philosophy
OpenMonetis prioritizes breathing room and visual clarity. Whitespace is intentional and strategic—surrounding headings, separating card groups, and framing key messages. The warm neutral backgrounds (`#F8F6F4`, `#F5F2EF`) create natural visual separation without hard borders. Minimum margin between major sections is `64px` vertically; minimum padding inside containers is `16px`.
### Border Radius Scale
- **Sharp Corners:** `0px` (utility container tops, category selectors)
- **Subtle Radius:** `9.2px` (buttons, small inputs, icon buttons)
- **Standard Radius:** `11.2px` (cards, standard containers, modals)
- **Rounded Top:** `15.2px 15.2px 0px 0px` (card headers, sheet-style containers)
- **Pill Shape:** `24px` (badges, full-rounded tags, avatar images)
- **Circle:** `50%` (avatar images, radial elements)
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (None) | No shadow, `border: 1px solid #F0EEEC` | Default cards, inputs, containers; baseline surfaces |
| Subtle (sm) | `0px 1px 3px rgba(0, 0, 0, 0.06)` | Secondary buttons, hover states on light surfaces |
| Medium (md) | `0px 4px 12px rgba(0, 0, 0, 0.08)` | Elevated cards on hover, floating actions |
| Deep (lg) | `0px 10px 24px rgba(0, 0, 0, 0.12)` | Modals, dropdowns, popover menus |
**Shadow Philosophy:**
The design system uses restrained shadow treatment aligned with a flat-modern aesthetic. Shadows emerge subtly on interaction (hover, focus) rather than as default styling. The primary depth cue is border color (`#F0EEEC`), which maintains visual hierarchy without excessive z-depth. When shadows are used, they employ warm-tinted blacks (`rgba(0, 0, 0, 0.060.12)`) to harmonize with the warm neutral palette.
## 7. Do's and Don'ts
### Do
- Use the primary orange (`#FF7733`) exclusively for the most important call-to-action buttons
- Apply generous padding (`24px48px`) around sections and inside cards for breathing room
- Stack elements vertically with `2432px` gaps for clear visual rhythm
- Use warm grays (`#443732`, `#2A2827`) for all body text to maintain the warm aesthetic
- Apply `9.2px` border radius consistently to all interactive elements (buttons, inputs)
- Keep line heights at `1.4×` or greater for comfortable reading on body text
- Use semantic colors (`#0E9D6E` success, `#F7A439` warning, `#F53F2D` error) intentionally
- Test contrast ratios; maintain WCAG AA minimum (4.5:1 for body text)
- Use the `Inter` typeface exclusively for consistency
- Implement focus states with a `3px` colored outline or border
### Don't
- Don't use orange anywhere except primary CTAs and critical highlights
- Don't reduce padding below `12px` inside cards or `8px` inside compact buttons
- Don't use dark backgrounds (`#0F0D0C`) for body text on light surfaces; stick to `#2A2827` or `#443732`
- Don't apply shadows as default styling; reserve them for elevated states (hover, focus, modal)
- Don't mix border radius values on the same component type; stick to defined scale
- Don't increase line height above `1.6×` for headings; tighten for impact
- Don't use the error color (`#F53F2D`) for general emphasis; reserve for genuine errors
- Don't create new colors outside the palette; use opacity if gradation is needed
- Don't apply multiple shadows to a single element; layer a maximum of two shadow values
- Don't forget to include focus/keyboard navigation states on all interactive elements
## 8. Responsive Behavior
### Breakpoints
| Breakpoint | Width | Key Changes |
|-----------|-------|-------------|
| Mobile | `375px599px` | Single column; container padding `16px`; font sizes reduce 12 sizes; gap scale halved |
| Tablet | `600px1023px` | Two-column grid; container padding `32px`; button height `36px`; heading sizes reduce slightly |
| Desktop | `1024px+` | Full 12-column grid; container padding `48px`; full-scale typography; max-width `1440px` |
### Touch Targets
- **Minimum Interactive Size:** `44px` × `44px` for mobile; `40px` × `40px` for desktop
- **Button Padding:** `12px` vertical, `16px` horizontal (minimum)
- **Link Padding:** `6px` vertical, `8px` horizontal minimum
- **Icon Button:** `32px` × `32px` minimum (32px on mobile preferred)
- **Spacing Between Targets:** `8px` minimum to avoid accidental activation
### Collapsing Strategy
- **Navigation:** Horizontal nav on desktop collapses to hamburger menu on tablet; menu items stack vertically with `12px` gap
- **Grid:** 12-column layout on desktop → 6-column on tablet → 2-column (stacked) on mobile
- **Cards:** Three-column card layouts collapse to single column on mobile; padding reduces from `24px` to `16px`
- **Typography:** Display (60px) → 36px on tablet → 28px on mobile; body (20px) → 18px on tablet → 16px on mobile
- **Spacing:** All spacing scale values reduce by 2533% on mobile (e.g., `24px` gap → `16px` on tablet, `12px` on mobile)
- **Buttons:** Full-width on mobile (padding `0px`); inline (auto-width) on desktop
- **Inputs:** Full-width on mobile; constrained width on desktop
## 9. Agent Prompt Guide
### Quick Color Reference
- **Primary CTA:** Warm Orange (`#FF7733`) — Buttons, highlights, key interactions
- **Primary Text:** Warm Brown (`#443732`) — Headings, strong emphasis
- **Secondary Text:** Dark Neutral (`#2A2827`) — Body text, card content
- **Background:** White (`#FFFFFF`) — Cards, primary surfaces
- **Background Alt:** Cream (`#FCF7F6`) — Alternative surfaces, light containers
- **Border:** Pale Gray (`#F0EEEC`) — Card borders, divider lines
- **Success:** Green (`#0E9D6E`) — Confirmation, positive states
- **Warning:** Amber (`#F7A439`) — Cautions, attention states
- **Error:** Red-Orange (`#F53F2D`) — Errors, destructive actions
- **Disabled:** Light Gray (`#E8E3E0`) — Inactive elements, inaccessible states
### Iteration Guide
1. **Always use `#FF7733` for primary buttons** and all main call-to-action elements; secondary buttons use `#FFFFFF` with `#F0EEEC` border
2. **Typography is always `Inter`** with weights 400 (body), 500 (emphasis), and 600 (headings); size hierarchy: 14 → 16 → 20 → 36 → 60px
3. **Spacing base is `4px`**; use multiples from the scale (8, 12, 16, 24, 32, 48, 64, 80, 96, 128px); never arbitrary values
4. **Border radius:** Apply `9.2px` to all buttons and inputs, `11.2px` to cards, `15.2px 15.2px 0px 0px` to top-bordered containers
5. **Cards default to `#FFFFFF` background with `1px solid #F0EEEC` border**; add shadow only on hover (0px 4px 12px rgba(0, 0, 0, 0.08))
6. **Form inputs:** Padding `12px 16px`, border `1px solid #F0EEEC`, focus state `border: 1px solid #FF7733` + `box-shadow: 0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
7. **Navigation background is always `#FF7733`** with white text; links in content use `#443732` with underline on active
8. **Maintain 1.4× line height minimum for body text**; tighten headings to 1:1 or 1.2:1 ratio
9. **Contrast validation:** Text `#0F0D0C` or `#2A2827` on light backgrounds (WCAG AA); text `#FFFFFF` on `#FF7733` (WCAG AAA)
10. **Responsive collapse:** Reduce padding and font by 25% on mobile; stack multi-column layouts to single column; full-width buttons on mobile only

View File

@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
# Nota: a integração Logo.dev não precisa mais de build args. O token
# `LOGO_DEV_TOKEN` é lido em runtime no servidor — basta configurá-lo no
# host (Coolify, Railway, etc.) junto com `LOGO_DEV_SECRET_KEY`.
# Build da aplicação Next.js # Build da aplicação Next.js
RUN pnpm build RUN pnpm build
@@ -105,7 +109,7 @@ USER nextjs
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
# Entrypoint: roda migrations e depois executa o CMD # Entrypoint: roda migrations e depois executa o CMD
ENTRYPOINT ["/app/docker-entrypoint.sh"] ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="./public/images/logo_small.png" alt="OpenMonetis Logo" height="80" /> <img src="./public/images/logo_small.svg" alt="OpenMonetis Logo" height="80" />
</p> </p>
<p align="center"> <p align="center">
@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.4.0-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.5.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/) [![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/) [![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/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -20,11 +20,7 @@
--- ---
<p align="center"> <p align="center">
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" /> <img src="./public/images/dashboard-preview-light.png" alt="Dashboard Preview" width="800" />
</p>
<p align="center">
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
</p> </p>
--- ---
@@ -196,13 +192,10 @@ cp .env.example .env
# 4. Suba o banco # 4. Suba o banco
pnpm docker:db pnpm docker:db
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup) # 5. Aplique o schema no banco (apenas no primeiro setup)
pnpm db:extensions
# 6. Aplique o schema no banco (apenas no primeiro setup)
pnpm db:push pnpm db:push
# 7. Inicie o app com hot-reload # 6. Inicie o app com hot-reload
pnpm dev pnpm dev
``` ```
@@ -240,7 +233,6 @@ pnpm lint:fix # Biome auto-fix
pnpm db:generate # Gerar migrations pnpm db:generate # Gerar migrations
pnpm db:migrate # Executar migrations pnpm db:migrate # Executar migrations
pnpm db:push # Push schema direto (dev) pnpm db:push # Push schema direto (dev)
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
pnpm db:studio # Drizzle Studio (UI visual) pnpm db:studio # Drizzle Studio (UI visual)
``` ```
@@ -291,8 +283,7 @@ docker compose up -d app
docker compose exec app sh # Shell da aplicação docker compose exec app sh # Shell da aplicação
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
docker compose ps # Status docker compose ps # Status
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup pnpm backup # Backup (ver seção Backup)
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
``` ```
### Customizando portas ### Customizando portas
@@ -318,9 +309,9 @@ Cada execução gera **3 arquivos** em `backup/`:
| Arquivo | Conteúdo | Uso | | Arquivo | Conteúdo | Uso |
|---|---|---| |---|---|---|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` | | `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom dos schemas `public` + `drizzle` | Restore completo via `pg_restore` |
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade | | `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL compactado dos schemas `public` + `drizzle` | Inspeção manual, portabilidade |
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente | | `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados do schema `public` (sem DDL) | Migração parcial, seed de outro ambiente |
### Modos de conexão ### Modos de conexão
@@ -354,16 +345,19 @@ crontab -e
### Restore ### Restore
```bash ```bash
# A partir do .dump (recomendado — mais rápido) # 1. Zerar o banco
pg_restore --clean --no-owner --no-privileges \ docker exec <container-db> psql -U openmonetis -d openmonetis_db \
-d "postgresql://user:senha@host:5432/openmonetis_db" \ -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
# A partir do .sql.gz (banco local via Docker) # 2. Restaurar schema + dados (um comando)
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \ docker exec -i <container-db> pg_restore \
docker compose exec -T db psql -U openmonetis -d openmonetis_db -U openmonetis -d openmonetis_db \
--clean --if-exists --disable-triggers --no-owner --no-privileges \
< backup/openmonetis_YYYY-MM-DD_HH-MM.dump
``` ```
> `--disable-triggers` é necessário para evitar erros de FK durante o restore (os dados são inseridos fora de ordem). O usuário `openmonetis` tem permissão para isso.
--- ---
## ☁️ Storage S3 Compatível ## ☁️ Storage S3 Compatível
@@ -390,6 +384,42 @@ S3_BUCKET=
--- ---
## 🏷️ Logos de Estabelecimentos (Logo.dev)
O app exibe logos automáticos de marcas na coluna de estabelecimentos nos lançamentos. A integração usa a [Logo.dev](https://www.logo.dev) e é opcional — sem ela, o app exibe as iniciais coloridas normalmente.
### Variáveis
```env
LOGO_DEV_TOKEN=pk_... # token público (obrigatório para exibir logos)
LOGO_DEV_SECRET_KEY=sk_... # chave secreta (obrigatório para o picker de busca)
```
> **Atualizando da v2.4.1 ou anterior:** a variável foi renomeada de `NEXT_PUBLIC_LOGO_DEV_TOKEN` para `LOGO_DEV_TOKEN`. Renomeie no seu `.env` (ou nas variáveis do Coolify/host) e remova o secret homônimo do GitHub Actions — ele não é mais usado. Não há outra etapa de migração.
### Como configurar
Ambas as variáveis são lidas em **runtime** pelo servidor Next.js. Não há mais nenhuma etapa no CI nem `--build-arg` no Docker.
**Self-hosted via Docker Hub (Coolify, Railway, etc.):**
1. Adicione `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` nas variáveis de ambiente do host
2. Reinicie o container — pronto
**Desenvolvimento local:**
Adicione as duas no `.env` e rode `pnpm dev`.
### Como usar
Após configurado, passe o mouse sobre o avatar de qualquer estabelecimento nos lançamentos — um ícone de lápis aparece. Clique para abrir o picker, busque pelo nome da marca e selecione o logo desejado. O mapeamento fica salvo por usuário no banco.
### Arquitetura
O token **nunca chega ao cliente**. O servidor constrói a URL `https://img.logo.dev/{domain}?token=...` nos endpoints `/api/logo/mapping` e `/api/logo/search`, e o cliente apenas consome a URL pronta. Um Context Provider (`LogoDevProvider`) propaga a flag `enabled` para os componentes que decidem se renderizam o picker.
---
## 🔐 Variáveis de Ambiente ## 🔐 Variáveis de Ambiente
**Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`. **Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
@@ -437,6 +467,11 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=
``` ```
--- ---

View File

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

View File

@@ -10,6 +10,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db} POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:
@@ -40,7 +41,7 @@ services:
condition: service_healthy condition: service_healthy
required: false required: false
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -1,15 +1,5 @@
#!/bin/sh #!/bin/sh
echo "Habilitando extensão pgcrypto..."
node -e "
const { Client } = require('/app/migrate/node_modules/pg');
const c = new Client({ connectionString: process.env.DATABASE_URL });
c.connect()
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
.then(() => c.end())
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
"
echo "Rodando migrations..." echo "Rodando migrations..."
MIGRATED=0 MIGRATED=0
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do

View File

@@ -0,0 +1,24 @@
DROP INDEX "tokens_api_user_id_idx";--> statement-breakpoint
DROP INDEX "cartoes_user_id_status_idx";--> statement-breakpoint
DROP INDEX "dashboard_notification_states_user_id_archived_idx";--> statement-breakpoint
DROP INDEX "contas_user_id_status_idx";--> statement-breakpoint
DROP INDEX "antecipacoes_parcelas_series_id_idx";--> statement-breakpoint
DROP INDEX "pagadores_user_id_status_idx";--> statement-breakpoint
DROP INDEX "pagadores_user_id_role_idx";--> statement-breakpoint
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "anexos_user_id_idx" ON "anexos" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "orcamentos_categoria_id_idx" ON "orcamentos" USING btree ("categoria_id");--> statement-breakpoint
CREATE INDEX "cartoes_conta_id_idx" ON "cartoes" USING btree ("conta_id");--> statement-breakpoint
CREATE INDEX "import_category_mappings_category_id_idx" ON "import_category_mappings" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "pre_lancamentos_lancamento_id_idx" ON "pre_lancamentos" USING btree ("lancamento_id");--> statement-breakpoint
CREATE INDEX "antecipacoes_parcelas_lancamento_id_idx" ON "antecipacoes_parcelas" USING btree ("lancamento_id");--> statement-breakpoint
CREATE INDEX "antecipacoes_parcelas_pagador_id_idx" ON "antecipacoes_parcelas" USING btree ("pagador_id");--> statement-breakpoint
CREATE INDEX "antecipacoes_parcelas_categoria_id_idx" ON "antecipacoes_parcelas" USING btree ("categoria_id");--> statement-breakpoint
CREATE INDEX "anotacoes_user_id_idx" ON "anotacoes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "compartilhamentos_pagador_shared_with_user_id_idx" ON "compartilhamentos_pagador" USING btree ("shared_with_user_id");--> statement-breakpoint
CREATE INDEX "compartilhamentos_pagador_created_by_user_id_idx" ON "compartilhamentos_pagador" USING btree ("created_by_user_id");--> statement-breakpoint
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "lancamentos_conta_id_idx" ON "lancamentos" USING btree ("conta_id");--> statement-breakpoint
CREATE INDEX "lancamentos_categoria_id_idx" ON "lancamentos" USING btree ("categoria_id");--> statement-breakpoint
CREATE INDEX "lancamentos_antecipacao_id_idx" ON "lancamentos" USING btree ("antecipacao_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "lancamentos" ADD COLUMN "split_group_id" uuid;--> statement-breakpoint
CREATE INDEX "lancamentos_user_id_split_group_id_idx" ON "lancamentos" USING btree ("user_id","split_group_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "pagadores" ALTER COLUMN "share_code" DROP DEFAULT;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "cartoes" ALTER COLUMN "limite" SET DEFAULT '0';--> statement-breakpoint
UPDATE "cartoes" SET "limite" = '0' WHERE "limite" IS NULL;--> statement-breakpoint
ALTER TABLE "cartoes" ALTER COLUMN "limite" SET NOT NULL;

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

@@ -1,181 +1,209 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1762993507299, "when": 1762993507299,
"tag": "0000_flashy_manta", "tag": "0000_flashy_manta",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1765199006435, "when": 1765199006435,
"tag": "0001_young_mister_fear", "tag": "0001_young_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1765200545692, "when": 1765200545692,
"tag": "0002_slimy_flatman", "tag": "0002_slimy_flatman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1767102605526, "when": 1767102605526,
"tag": "0003_green_korg", "tag": "0003_green_korg",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1767104066872, "when": 1767104066872,
"tag": "0004_acoustic_mach_iv", "tag": "0004_acoustic_mach_iv",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1767106121811, "when": 1767106121811,
"tag": "0005_adorable_bruce_banner", "tag": "0005_adorable_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1767107487318, "when": 1767107487318,
"tag": "0006_youthful_mister_fear", "tag": "0006_youthful_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1767118780033, "when": 1767118780033,
"tag": "0007_sturdy_kate_bishop", "tag": "0007_sturdy_kate_bishop",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1767125796314, "when": 1767125796314,
"tag": "0008_fat_stick", "tag": "0008_fat_stick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1768925100873, "when": 1768925100873,
"tag": "0009_add_dashboard_widgets", "tag": "0009_add_dashboard_widgets",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1769447087678, "when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns", "tag": "0011_remove_unused_inbox_columns",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1769533200000, "when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese", "tag": "0012_rename_tables_to_portuguese",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1769523352777, "when": 1769523352777,
"tag": "0013_fancy_rick_jones", "tag": "0013_fancy_rick_jones",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1770332054481, "when": 1770332054481,
"tag": "0015_concerned_kat_farrell", "tag": "0015_concerned_kat_farrell",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "7", "version": "7",
"when": 1772400510326, "when": 1772400510326,
"tag": "0017_previous_warstar", "tag": "0017_previous_warstar",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "7", "version": "7",
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773699152928, "when": 1773699152928,
"tag": "0019_ordinary_wild_pack", "tag": "0019_ordinary_wild_pack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "7", "version": "7",
"when": 1773841892114, "when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints", "tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 21,
"version": "7", "version": "7",
"when": 1774033320053, "when": 1774033320053,
"tag": "0021_careful_malcolm_colcord", "tag": "0021_careful_malcolm_colcord",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 22,
"version": "7", "version": "7",
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 23, "idx": 23,
"version": "7", "version": "7",
"when": 1774529878374, "when": 1774529878374,
"tag": "0023_sturdy_wolfpack", "tag": "0023_sturdy_wolfpack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 24, "idx": 24,
"version": "7", "version": "7",
"when": 1774891206703, "when": 1774891206703,
"tag": "0024_petite_lucky_pierre", "tag": "0024_petite_lucky_pierre",
"breakpoints": true "breakpoints": true
} },
] {
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1777153372633,
"tag": "0028_fancy_reaper",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1777648189399,
"tag": "0029_friendly_spitfire",
"breakpoints": true
}
]
} }

View File

@@ -8,7 +8,6 @@ const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
cacheComponents: true, cacheComponents: true,
reactCompiler: true, reactCompiler: true,
images: { images: {
remotePatterns: [ remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"), new URL("https://lh3.googleusercontent.com/**"),

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.4.0", "version": "2.5.0",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -15,7 +15,6 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs", "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up": "docker compose up -d", "docker:up": "docker compose up -d",
@@ -28,19 +27,20 @@
"//docker:logs": "Acompanha logs de todos os containers em tempo real", "//docker:logs": "Acompanha logs de todos os containers em tempo real",
"docker:update": "docker compose pull && docker compose up -d", "docker:update": "docker compose pull && docker compose up -d",
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia", "//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
"backup": "bash scripts/backup.sh" "backup": "bash scripts/backup.sh",
"mockup": "tsx scripts/mock-data.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.69", "@ai-sdk/anthropic": "^3.0.74",
"@ai-sdk/google": "^3.0.63", "@ai-sdk/google": "^3.0.67",
"@ai-sdk/openai": "^3.0.52", "@ai-sdk/openai": "^3.0.57",
"@aws-sdk/client-s3": "^3.1030.0", "@aws-sdk/client-s3": "^3.1040.0",
"@aws-sdk/s3-request-presigner": "^3.1030.0", "@aws-sdk/s3-request-presigner": "^3.1040.0",
"@better-auth/passkey": "^1.6.2", "@better-auth/passkey": "^1.6.9",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.5.1", "@openrouter/ai-sdk-provider": "^2.9.0",
"@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-checkbox": "1.3.3",
@@ -63,11 +63,11 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.100.7",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.159", "ai": "^6.0.173",
"better-auth": "1.6.2", "better-auth": "1.6.9",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -77,19 +77,19 @@
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"next": "16.2.3", "next": "16.2.4",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.7.284",
"pg": "8.20.0", "pg": "8.20.0",
"react": "19.2.5", "react": "19.2.5",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.11.0", "resend": "^6.12.2",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"zod": "4.3.6" "zod": "4.4.1"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
@@ -97,8 +97,8 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.11", "@biomejs/biome": "2.4.13",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.2.4",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.6.0", "@types/node": "25.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
@@ -106,9 +106,9 @@
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.4.1", "knip": "^6.10.0",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.4",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2" "typescript": "6.0.3"
} }
} }

2310
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,5 +5,6 @@ export const inter = Inter({
display: "swap", display: "swap",
variable: "--font-inter", variable: "--font-inter",
fallback: ["ui-sans-serif", "system-ui"], fallback: ["ui-sans-serif", "system-ui"],
weight: ["500", "600", "700"],
preload: true, preload: true,
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" role="img" aria-label="OpenMonetis">
<path fill="#ff7733" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

View File

@@ -58,21 +58,26 @@ DATA_FILE="$BACKUP_DIR/openmonetis_${TIMESTAMP}.data.sql.gz"
log "Iniciando backup (modo: $DB_MODE)..." log "Iniciando backup (modo: $DB_MODE)..."
# Schemas relevantes do OpenMonetis (descarta lixo Supabase: auth, realtime, storage, vault, graphql, etc.)
SCHEMA_FLAGS=(--schema=public --schema=drizzle)
# --- Dump --- # --- Dump ---
if [[ "$DB_MODE" == "remote" ]]; then if [[ "$DB_MODE" == "remote" ]]; then
# --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente) # --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente)
pg_dump --format=custom --no-owner --no-privileges \ pg_dump --format=custom --no-owner --no-privileges \
"${SCHEMA_FLAGS[@]}" \
"$REMOTE_DB_URL" > "$DUMP_FILE" "$REMOTE_DB_URL" > "$DUMP_FILE"
pg_dump --no-owner --no-privileges \ pg_dump --no-owner --no-privileges \
"${SCHEMA_FLAGS[@]}" \
"$REMOTE_DB_URL" | gzip > "$SQL_FILE" "$REMOTE_DB_URL" | gzip > "$SQL_FILE"
elif [[ "$DB_MODE" == "docker" ]]; then elif [[ "$DB_MODE" == "docker" ]]; then
docker exec "$DOCKER_CONTAINER" pg_dump \ docker exec "$DOCKER_CONTAINER" pg_dump \
-U "$DOCKER_DB_USER" -Fc "$DOCKER_DB_NAME" > "$DUMP_FILE" -U "$DOCKER_DB_USER" -Fc "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" > "$DUMP_FILE"
docker exec "$DOCKER_CONTAINER" pg_dump \ docker exec "$DOCKER_CONTAINER" pg_dump \
-U "$DOCKER_DB_USER" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE" -U "$DOCKER_DB_USER" "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
else else
log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'." log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'."

View File

@@ -44,6 +44,7 @@ import {
PAYER_ROLE_THIRD_PARTY, PAYER_ROLE_THIRD_PARTY,
PAYER_STATUS_OPTIONS, PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants"; } from "@/shared/lib/payers/constants";
import { generateShareCode } from "@/shared/lib/payers/share-code";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils"; import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
import { import {
addMonthsToDate, addMonthsToDate,
@@ -537,6 +538,7 @@ async function ensureAdminPayer(targetUser: typeof user.$inferSelect) {
note: null, note: null,
role: PAYER_ROLE_ADMIN, role: PAYER_ROLE_ADMIN,
isAutoSend: false, isAutoSend: false,
shareCode: generateShareCode(),
userId: targetUser.id, userId: targetUser.id,
}) })
.returning({ id: payers.id, name: payers.name }); .returning({ id: payers.id, name: payers.name });
@@ -870,6 +872,7 @@ async function main() {
note: definition.note, note: definition.note,
role: PAYER_ROLE_THIRD_PARTY, role: PAYER_ROLE_THIRD_PARTY,
isAutoSend: definition.isAutoSend, isAutoSend: definition.isAutoSend,
shareCode: generateShareCode(),
userId: targetUser.id, userId: targetUser.id,
}) })
.returning({ id: payers.id }); .returning({ id: payers.id });

View File

@@ -1,45 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// Load environment variables from .env
config();
async function initDatabase() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("DATABASE_URL environment variable is required");
process.exit(1);
}
const pool = new Pool({ connectionString: databaseUrl });
const db = drizzle(pool);
try {
console.log("🔧 Initializing database extensions...");
// Read and execute init.sql as a single query
const initSqlPath = path.join(
process.cwd(),
"scripts",
"postgres",
"init.sql",
);
const initSql = fs.readFileSync(initSqlPath, "utf-8");
console.log("Executing init.sql...");
await db.execute(initSql);
console.log("✅ Database initialization completed");
} catch (error) {
console.error("❌ Database initialization failed:", error);
process.exit(1);
} finally {
await pool.end();
}
}
initDatabase();

View File

@@ -1,10 +0,0 @@
-- Script de inicialização do PostgreSQL para Docker
-- Este script é executado automaticamente quando o banco é criado pela primeira vez
-- Habilitar extensão pgcrypto (necessária para gen_random_bytes usado pelo Drizzle)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Log de sucesso
DO $$
BEGIN
RAISE NOTICE '✅ Extensão pgcrypto habilitada com sucesso';
END $$;

23
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Logo } from "@/shared/components/logo";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="pointer-events-none absolute inset-0 overflow-hidden flex items-center justify-center">
<div className="absolute -right-32 top-0 h-96 w-96 rounded-full bg-primary/10 blur-3xl animate-blob mix-blend-multiply" />
<div className="absolute -left-32 bottom-0 h-96 w-96 rounded-full bg-primary/7 blur-3xl animate-blob animation-delay-2000 mix-blend-multiply" />
<div className="absolute -bottom-32 left-1/2 h-80 w-80 rounded-full bg-secondary/30 blur-3xl animate-blob animation-delay-4000 mix-blend-multiply" />
</div>
<div className="relative mb-6 flex md:hidden z-20">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">{children}</div>
</div>
);
}

View File

@@ -1,21 +1,5 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Logo } from "@/shared/components/logo";
export default function LoginPage() { export default function LoginPage() {
return ( return <LoginForm />;
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
</div>
<div className="relative mb-6 flex md:hidden">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">
<LoginForm />
</div>
</div>
);
} }

View File

@@ -1,21 +1,5 @@
import { SignupForm } from "@/features/auth/components/signup-form"; import { SignupForm } from "@/features/auth/components/signup-form";
import { Logo } from "@/shared/components/logo";
export default function SignupPage() { export default function SignupPage() {
return ( return <SignupForm />;
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
</div>
<div className="relative mb-6 flex md:hidden">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">
<SignupForm />
</div>
</div>
);
} }

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { connection } from "next/server"; import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog";
import type { Account } from "@/features/accounts/components/types"; import type { Account } from "@/features/accounts/components/types";
import { import {
fetchAccountData, fetchAccountData,
@@ -141,6 +142,13 @@ export default async function Page({ params, searchParams }: PageProps) {
totalIncomes={totalIncomes} totalIncomes={totalIncomes}
totalExpenses={totalExpenses} totalExpenses={totalExpenses}
logo={account.logo} logo={account.logo}
balanceAdjustment={
<AdjustBalanceDialog
accountId={account.id}
period={selectedPeriod}
currentBalance={currentBalance}
/>
}
actions={ actions={
<AccountDialog <AccountDialog
mode="update" mode="update"

View File

@@ -20,22 +20,13 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value; return Array.isArray(value) ? (value[0] ?? null) : value;
}; };
const capitalize = (value: string) =>
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection(); await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { const { period: selectedPeriod } = parsePeriodParam(periodoParam);
period: selectedPeriod,
monthName: rawMonthName,
year,
} = parsePeriodParam(periodoParam);
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { budgets, categoriesOptions } = await fetchBudgetsForUser( const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId, userId,
@@ -49,7 +40,6 @@ export default async function Page({ searchParams }: PageProps) {
budgets={budgets} budgets={budgets}
categories={categoriesOptions} categories={categoriesOptions}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
/> />
</main> </main>
); );

View File

@@ -118,6 +118,8 @@ export default async function Page({ params, searchParams }: PageProps) {
financialAccount.id === card.accountId, financialAccount.id === card.accountId,
)?.name ?? "Conta"; )?.name ?? "Conta";
const limitAmount = Number(card.limit);
const cardDialogData: Card = { const cardDialogData: Card = {
id: card.id, id: card.id,
name: card.name, name: card.name,
@@ -127,19 +129,14 @@ export default async function Page({ params, searchParams }: PageProps) {
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note ?? null, note: card.note ?? null,
logo: card.logo, logo: card.logo,
limit: limit: limitAmount,
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
accountId: card.accountId, accountId: card.accountId,
accountName, accountName,
limitInUse: 0, limitInUse: 0,
limitAvailable: null, limitAvailable: limitAmount,
}; };
const { totalAmount, invoiceStatus, paymentDate } = invoiceData; const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice( const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1, 1,
@@ -163,6 +160,12 @@ export default async function Page({ params, searchParams }: PageProps) {
limitAmount={limitAmount} limitAmount={limitAmount}
invoiceStatus={invoiceStatus} invoiceStatus={invoiceStatus}
paymentDate={paymentDate} paymentDate={paymentDate}
defaultPaymentAccountId={card.accountId}
paymentAccountOptions={accountOptions.map((option) => ({
value: option.value,
label: option.label,
logo: option.logo ?? null,
}))}
logo={card.logo} logo={card.logo}
actions={ actions={
<CardDialog <CardDialog

View File

@@ -1,6 +1,6 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries"; import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget"; import { CategoryHistoryWidget } from "@/features/dashboard/components/widgets/category-history-widget";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { getCurrentPeriod } from "@/shared/utils/period"; import { getCurrentPeriod } from "@/shared/utils/period";

View File

@@ -2,10 +2,13 @@ import { connection } from "next/server";
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable"; import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards"; import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
import { extractDashboardLogoNames } from "@/features/dashboard/extract-logo-names";
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries"; import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/page-helpers"; import { getSingleParam } from "@/features/transactions/page-helpers";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -25,17 +28,24 @@ export default async function Page({ searchParams }: PageProps) {
await fetchDashboardPageData(user.id, selectedPeriod); await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences; const { dashboardWidgets } = preferences;
const logoMappings = await prefetchLogoMappings(
user.id,
extractDashboardLogoNames(dashboardData),
);
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} /> <DashboardWelcome name={user.name} />
<MonthNavigation /> <MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} /> <DashboardMetricsCards metrics={dashboardData.metrics} />
<DashboardGridEditable <LogoPrefetchProvider mappings={logoMappings}>
data={dashboardData} <DashboardGridEditable
period={selectedPeriod} data={dashboardData}
initialPreferences={dashboardWidgets} period={selectedPeriod}
quickActionOptions={quickActionOptions} initialPreferences={dashboardWidgets}
/> quickActionOptions={quickActionOptions}
/>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -1,9 +1,10 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server"; import { getUserSession } from "@/shared/lib/auth/server";
import { isLogoDevEnabled } from "@/shared/lib/logo/server";
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@@ -13,33 +14,25 @@ export default async function DashboardLayout({
await connection(); await connection();
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id); const navbarData = await fetchDashboardNavbarData(session.user.id);
const logoDevEnabled = isLogoDevEnabled();
return ( return (
<PrivacyProvider> <LogoDevProvider enabled={logoDevEnabled}>
<AppNavbar <PrivacyProvider>
user={{ ...session.user, image: session.user.image ?? null }} <AppNavbar
pagadorAvatarUrl={navbarData.pagadorAvatarUrl} user={{ ...session.user, image: session.user.image ?? null }}
preLancamentosCount={navbarData.preLancamentosCount} pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
notificationsSnapshot={navbarData.notificationsSnapshot} preLancamentosCount={navbarData.preLancamentosCount}
/> notificationsSnapshot={navbarData.notificationsSnapshot}
<div className="relative flex flex-1 flex-col pt-16"> />
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36"> <div className="relative flex flex-1 flex-col pt-16">
<DotPattern <div className="@container/main flex flex-1 flex-col gap-2">
width={20} <div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
height={20} {children}
cx={1.25} </div>
cy={1.25}
cr={1.25}
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
/>
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
</div>
<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> </div>
</div> </PrivacyProvider>
</PrivacyProvider> </LogoDevProvider>
); );
} }

View File

@@ -41,6 +41,7 @@ import {
fetchRecentEstablishments, fetchRecentEstablishments,
fetchTransactionFilterSources, fetchTransactionFilterSources,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { import {
@@ -50,6 +51,7 @@ import {
TabsTrigger, TabsTrigger,
} from "@/shared/components/ui/tabs"; } from "@/shared/components/ui/tabs";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { getPayerAccess } from "@/shared/lib/payers/access"; import { getPayerAccess } from "@/shared/lib/payers/access";
import { import {
fetchPagadorBoletoItems, fetchPagadorBoletoItems,
@@ -82,6 +84,7 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
searchFilter: null, searchFilter: null,
settledFilter: null, settledFilter: null,
attachmentFilter: null, attachmentFilter: null,
dividedFilter: null,
}; };
const createEmptySlugMaps = (): SlugMaps => ({ const createEmptySlugMaps = (): SlugMaps => ({
@@ -307,104 +310,113 @@ export default async function Page({ params, searchParams }: PageProps) {
lancamentoCount: transactionData.length, lancamentoCount: transactionData.length,
}; };
const logoMappings = await prefetchLogoMappings(dataOwnerId, [
...transactionData.map((t) => t.name),
...boletoItems.map((b) => b.name),
]);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<Tabs defaultValue="profile" className="w-full"> <LogoPrefetchProvider mappings={logoMappings}>
<TabsList className="mb-2"> <Tabs defaultValue="profile" className="w-full">
<TabsTrigger value="profile">Perfil</TabsTrigger> <TabsList className="mb-2">
<TabsTrigger value="painel">Painel</TabsTrigger> <TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger> <TabsTrigger value="painel">Painel</TabsTrigger>
</TabsList> <TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
<PayerHeaderCard </TabsList>
payer={payerData} <PayerHeaderCard
selectedPeriod={selectedPeriod} payer={payerData}
summary={summaryPreview} selectedPeriod={selectedPeriod}
/> summary={summaryPreview}
/>
<TabsContent value="profile" className="space-y-4"> <TabsContent value="profile" className="space-y-4">
<PagadorInfoCard payer={payerData} /> <PagadorInfoCard payer={payerData} />
{canEdit && payerData.shareCode ? ( {canEdit && payerData.shareCode ? (
<PayerSharingCard <PayerSharingCard
payerId={pagador.id} payerId={pagador.id}
shareCode={payerData.shareCode} shareCode={payerData.shareCode}
shares={payerSharesData} shares={payerSharesData}
/> />
) : null} ) : null}
{!canEdit && currentUserShare ? ( {!canEdit && currentUserShare ? (
<PayerLeaveShareCard <PayerLeaveShareCard
shareId={currentUserShare.id} shareId={currentUserShare.id}
pagadorName={payerData.name} pagadorName={payerData.name}
createdAt={currentUserShare.createdAt} createdAt={currentUserShare.createdAt}
/> />
) : null} ) : null}
</TabsContent> </TabsContent>
<TabsContent value="painel" className="space-y-4"> <TabsContent value="painel" className="space-y-4">
<section className="grid gap-3 lg:grid-cols-2"> <section className="grid gap-3 lg:grid-cols-2">
<PayerMonthlySummaryCard <PayerMonthlySummaryCard
periodLabel={periodLabel} periodLabel={periodLabel}
breakdown={monthlyBreakdown} breakdown={monthlyBreakdown}
/> />
<PayerHistoryCard data={historyData} /> <PayerHistoryCard data={historyData} />
</section> </section>
<section className="grid gap-3 lg:grid-cols-3"> <section className="grid gap-3 lg:grid-cols-3">
<ExpandableWidgetCard <ExpandableWidgetCard
title="Minhas Faturas" title="Minhas Faturas"
subtitle="Valores por cartão neste período" subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />} icon={<RiBankCard2Line className="size-4" />}
> >
<PayerCardUsageCard items={cardUsage} /> <PayerCardUsageCard items={cardUsage} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
<ExpandableWidgetCard <ExpandableWidgetCard
title="Boletos" title="Boletos"
subtitle="Boletos registrados neste período" subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />} icon={<RiBarcodeLine className="size-4" />}
> >
<PayerBoletoCard items={boletoItems} /> <PayerBoletoCard items={boletoItems} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
<ExpandableWidgetCard <ExpandableWidgetCard
title="Status de Pagamento" title="Status de Pagamento"
subtitle="Situação das despesas no período" subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />} icon={<RiWallet3Line className="size-4" />}
> >
<PayerPaymentStatusCard data={paymentStatus} /> <PayerPaymentStatusCard data={paymentStatus} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
</section> </section>
</TabsContent> </TabsContent>
<TabsContent value="lancamentos"> <TabsContent value="lancamentos">
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<LancamentosSection <LancamentosSection
currentUserId={userId} currentUserId={userId}
transactions={transactionData} transactions={transactionData}
payerOptions={optionSets.payerOptions} payerOptions={optionSets.payerOptions}
splitPayerOptions={optionSets.splitPayerOptions} splitPayerOptions={optionSets.splitPayerOptions}
defaultPayerId={pagador.id} defaultPayerId={pagador.id}
accountOptions={optionSets.accountOptions} accountOptions={optionSets.accountOptions}
cardOptions={optionSets.cardOptions} cardOptions={optionSets.cardOptions}
categoryOptions={optionSets.categoryOptions} categoryOptions={optionSets.categoryOptions}
payerFilterOptions={payerFilterOptions} payerFilterOptions={payerFilterOptions}
categoryFilterOptions={optionSets.categoryFilterOptions} categoryFilterOptions={optionSets.categoryFilterOptions}
accountCardFilterOptions={optionSets.accountCardFilterOptions} accountCardFilterOptions={optionSets.accountCardFilterOptions}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} loggedUserOptionSets?.splitPayerOptions
importAccountOptions={loggedUserOptionSets?.accountOptions} }
importCardOptions={loggedUserOptionSets?.cardOptions} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
importCategoryOptions={loggedUserOptionSets?.categoryOptions} importAccountOptions={loggedUserOptionSets?.accountOptions}
/> importCardOptions={loggedUserOptionSets?.cardOptions}
</section> importCategoryOptions={loggedUserOptionSets?.categoryOptions}
</TabsContent> />
</Tabs> </section>
</TabsContent>
</Tabs>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -2,7 +2,7 @@ import { RiGroupLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description"; import PageDescription from "@/shared/components/page-description";
export const metadata = { export const metadata = {
title: "Pagadores", title: "Pessoas",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -14,7 +14,7 @@ export default function RootLayout({
<section className="space-y-6"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiGroupLine />} icon={<RiGroupLine />}
title="Pagadores" title="Pessoas"
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos." subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
/> />
{children} {children}

View File

@@ -1,7 +1,7 @@
import { Skeleton } from "@/shared/components/ui/skeleton"; import { Skeleton } from "@/shared/components/ui/skeleton";
/** /**
* Loading state para a página de pagadores * Loading state para a página de pessoas
* Layout: Header + Input de compartilhamento + Grid de cards * Layout: Header + Input de compartilhamento + Grid de cards
*/ */
export default function PagadoresLoading() { export default function PagadoresLoading() {
@@ -17,7 +17,7 @@ export default function PagadoresLoading() {
</div> </div>
</div> </div>
{/* Grid de cards de pagadores */} {/* Grid de cards de pessoas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-md border p-6 space-y-4"> <div key={i} className="rounded-md border p-6 space-y-4">

View File

@@ -40,7 +40,9 @@ export default async function Page({ searchParams }: PageProps) {
// Extract query params // Extract query params
const inicioParam = getSingleParam(resolvedSearchParams, "inicio"); const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
const fimParam = getSingleParam(resolvedSearchParams, "fim"); const fimParam = getSingleParam(resolvedSearchParams, "fim");
const categoriasParam = getSingleParam(resolvedSearchParams, "categories"); const categoriasParam =
getSingleParam(resolvedSearchParams, "categorias") ??
getSingleParam(resolvedSearchParams, "categories");
// Calculate default period (last 6 months) // Calculate default period (last 6 months)
const currentPeriod = getCurrentPeriod(); const currentPeriod = getCurrentPeriod();

View File

@@ -51,7 +51,7 @@ export default async function Page() {
<TabsTrigger value="passkeys">Passkeys</TabsTrigger> <TabsTrigger value="passkeys">Passkeys</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger> <TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive"> <TabsTrigger value="deletar" className="text-destructive">
Deletar conta Ações perigosas
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -190,7 +190,6 @@ export default async function Page() {
ou excluir sua conta inteira de forma irreversível. ou excluir sua conta inteira de forma irreversível.
</p> </p>
</div> </div>
<Separator />
<DeleteAccountForm /> <DeleteAccountForm />
</div> </div>
</Card> </Card>

View File

@@ -17,8 +17,10 @@ import {
fetchTransactionFilterSources, fetchTransactionFilterSources,
fetchTransactionsPage, fetchTransactionsPage,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -74,38 +76,45 @@ export default async function Page({ searchParams }: PageProps) {
payerRows: filterSources.payerRows, payerRows: filterSources.payerRows,
}); });
const logoMappings = await prefetchLogoMappings(
userId,
transactionData.map((t) => t.name),
);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<TransactionsPage <LogoPrefetchProvider mappings={logoMappings}>
currentUserId={userId} <TransactionsPage
transactions={transactionData} currentUserId={userId}
payerOptions={payerOptions} transactions={transactionData}
splitPayerOptions={splitPayerOptions} payerOptions={payerOptions}
defaultPayerId={defaultPayerId} splitPayerOptions={splitPayerOptions}
accountOptions={accountOptions} defaultPayerId={defaultPayerId}
cardOptions={cardOptions} accountOptions={accountOptions}
categoryOptions={categoryOptions} cardOptions={cardOptions}
payerFilterOptions={payerFilterOptions} categoryOptions={categoryOptions}
categoryFilterOptions={categoryFilterOptions} payerFilterOptions={payerFilterOptions}
accountCardFilterOptions={accountCardFilterOptions} categoryFilterOptions={categoryFilterOptions}
selectedPeriod={selectedPeriod} accountCardFilterOptions={accountCardFilterOptions}
estabelecimentos={estabelecimentos} selectedPeriod={selectedPeriod}
pagination={{ estabelecimentos={estabelecimentos}
page: transactionsPage.page, pagination={{
pageSize: transactionsPage.pageSize, page: transactionsPage.page,
totalItems: transactionsPage.totalItems, pageSize: transactionsPage.pageSize,
totalPages: transactionsPage.totalPages, totalItems: transactionsPage.totalItems,
}} totalPages: transactionsPage.totalPages,
exportContext={{ }}
source: "transactions", exportContext={{
period: selectedPeriod, source: "transactions",
filters: searchFilters, period: selectedPeriod,
}} filters: searchFilters,
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} }}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
/> attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -9,7 +9,6 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll"; import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
import { MobileNav } from "@/features/landing/components/mobile-nav"; import { MobileNav } from "@/features/landing/components/mobile-nav";
import { ScreenshotTabs } from "@/features/landing/components/screenshot-tabs";
import { SetupTabs } from "@/features/landing/components/setup-tabs"; import { SetupTabs } from "@/features/landing/components/setup-tabs";
import { import {
companionBanks, companionBanks,
@@ -30,7 +29,6 @@ import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell"
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
@@ -52,46 +50,47 @@ export default async function Page() {
{/* Navigation */} {/* Navigation */}
<NavbarShell> <NavbarShell>
{/* Center Navigation Links */} {/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2"> <nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => ( {navLinks.map(({ href, label }) => (
<a <Link
key={href} key={href}
href={href} href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors" className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
> >
{label} {label}
</a> </Link>
))} ))}
</nav> </nav>
<nav className="ml-auto flex items-center gap-2 md:gap-3"> <nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" /> <AnimatedThemeToggler variant="navbar" />
{!isPublicDomain && {!isPublicDomain &&
(session?.user ? ( (session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block"> <Link prefetch href="/dashboard" className="hidden md:block">
<Button <Button
variant="outline" variant="navbar"
size="sm" size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none" className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
> >
Dashboard Dashboard
</Button> </Button>
</Link> </Link>
) : ( ) : (
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-1">
<Link href="/login"> <Link href="/login">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none" className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
> >
Entrar Entrar
</Button> </Button>
</Link> </Link>
<Link href="/signup"> <Link href="/signup">
<Button <Button
variant="ghost"
size="sm" size="sm"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2" className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
> >
Começar Começar
</Button> </Button>
@@ -107,18 +106,6 @@ export default async function Page() {
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0"> <section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
<DotPattern
width={20}
height={20}
cx={1.25}
cy={1.25}
cr={1.25}
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
/>
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
</div>
<div className="max-w-8xl mx-auto px-4 relative"> <div className="max-w-8xl mx-auto px-4 relative">
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14"> <div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
<Badge variant="outline"> <Badge variant="outline">
@@ -220,31 +207,6 @@ export default async function Page() {
</div> </div>
</section> </section>
{/* Screenshots Gallery Section */}
<section id="telas" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl">
<AnimateOnScroll>
<div className="text-center mb-8 md:mb-12">
<Badge variant="outline" className="mb-4">
Conheça as telas
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Veja o que você pode fazer
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
Explore as principais telas do OpenMonetis
</p>
</div>
</AnimateOnScroll>
<AnimateOnScroll>
<ScreenshotTabs />
</AnimateOnScroll>
</div>
</div>
</section>
{/* Features Section */} {/* Features Section */}
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40"> <section id="funcionalidades" className="py-12 md:py-24 bg-muted/40">
<div className="max-w-8xl mx-auto px-4"> <div className="max-w-8xl mx-auto px-4">
@@ -265,72 +227,34 @@ export default async function Page() {
</AnimateOnScroll> </AnimateOnScroll>
<AnimateOnScroll> <AnimateOnScroll>
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mainFeatures.map((feature) => ( {[...mainFeatures, ...extraFeatures].map((feature) => (
<Card key={feature.title}> <Card key={feature.title}>
<CardContent className="pt-5 pb-5 md:pt-6"> <CardContent>
<div className="flex flex-col gap-3 md:gap-4"> <div className="flex items-center gap-3 mb-3">
<div <div
className="flex h-11 w-11 md:h-12 md:w-12 items-center justify-center rounded-lg" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${feature.colorVar} 20%, transparent)`,
}} }}
> >
<feature.icon <feature.icon
className="size-[22px] md:size-6" className="size-5"
style={{ color: feature.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <h3 className="font-semibold text-base leading-tight">
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2"> {feature.title}
{feature.title} </h3>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
</div> </div>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
</AnimateOnScroll> </AnimateOnScroll>
<AnimateOnScroll>
<div className="mt-8 md:mt-12">
<h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
Também inclui
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{extraFeatures.map((feature) => (
<div
key={feature.title}
className="flex items-start gap-3 rounded-lg border bg-card p-3 md:p-4"
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
style={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
}}
>
<feature.icon
className="size-[18px]"
style={{ color: feature.colorVar }}
/>
</div>
<div className="min-w-0">
<h4 className="font-semibold text-sm mb-0.5">
{feature.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -396,14 +320,14 @@ export default async function Page() {
{pwaHighlights.map((item) => ( {pwaHighlights.map((item) => (
<li key={item.title} className="flex items-start gap-3"> <li key={item.title} className="flex items-start gap-3">
<div <div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-[15px]" className="size-[15px]"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<p className="text-sm"> <p className="text-sm">
@@ -438,17 +362,19 @@ export default async function Page() {
pré-lançamentos automaticamente para você revisar na inbox. pré-lançamentos automaticamente para você revisar na inbox.
</p> </p>
<ol className="space-y-3 mb-6"> <ol className="space-y-3 mb-6">
{companionSteps.map((step, index) => ( {companionSteps.map((step) => (
<li key={step.title} className="flex items-start gap-3"> <li key={step.title} className="flex items-start gap-3">
<span <div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${step.colorVar} 20%, transparent)`,
color: step.colorVar,
}} }}
> >
{index + 1} <step.icon
</span> className="size-3.5"
style={{ color: "var(--foreground)" }}
/>
</div>
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">{step.title}</span> <span className="font-medium">{step.title}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -545,14 +471,14 @@ export default async function Page() {
<CardContent> <CardContent>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg" className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-6" className="size-6"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <div>
@@ -633,14 +559,14 @@ export default async function Page() {
<CardContent> <CardContent>
<div className="flex gap-3 md:gap-4"> <div className="flex gap-3 md:gap-4">
<div <div
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-lg" className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-[18px] md:size-5" className="size-[18px] md:size-5"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <div>

View File

@@ -1,26 +1,29 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries"; import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
/** /**
* GET /api/logo/mapping?name={name} * GET /api/logo/mapping?name={name}
* *
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento. * Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento,
* Usado pelo EstablishmentLogo para hidratar o domain salvo no banco. * junto com a `logoUrl` final (construída server-side com o token). O
* cliente usa `logoUrl` diretamente — sem precisar conhecer o token.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const session = await getOptionalUserSession(); const session = await getOptionalUserSession();
if (!session) { if (!session) {
return NextResponse.json({ domain: null }, { status: 200 }); return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const name = searchParams.get("name")?.trim(); const name = searchParams.get("name")?.trim();
if (!name) { if (!name) {
return NextResponse.json({ domain: null }, { status: 200 }); return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
} }
const domain = await fetchEstablishmentLogoDomain(session.user.id, name); const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
return NextResponse.json({ domain }); const logoUrl = buildLogoDevUrl(domain);
return NextResponse.json({ domain, logoUrl });
} }

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search"; const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search";
@@ -8,6 +9,10 @@ interface LogoResult {
domain: string; domain: string;
} }
interface LogoResultWithUrl extends LogoResult {
logoUrl: string | null;
}
async function searchByStrategy( async function searchByStrategy(
q: string, q: string,
strategy: "match" | "typeahead", strategy: "match" | "typeahead",
@@ -66,12 +71,14 @@ export async function GET(request: Request) {
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade) // Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
const seen = new Set<string>(); const seen = new Set<string>();
const merged: LogoResult[] = []; const merged: LogoResultWithUrl[] = [];
for (const result of [...matchResults, ...typeaheadResults]) { for (const result of [...matchResults, ...typeaheadResults]) {
if (!seen.has(result.domain)) { if (!seen.has(result.domain)) {
seen.add(result.domain); seen.add(result.domain);
merged.push(result); // logoUrl é construída server-side com o token — o cliente nunca
// precisa conhecer LOGO_DEV_TOKEN para renderizar a imagem.
merged.push({ ...result, logoUrl: buildLogoDevUrl(result.domain) });
if (merged.length >= 20) break; if (merged.length >= 20) break;
} }
} }

View File

@@ -8,9 +8,9 @@
} }
:root { :root {
--background: oklch(97.412% 0.00332 67.032); --background: oklch(95.99% 0.00411 55.512);
--foreground: oklch(27% 0.008 45); --foreground: oklch(27% 0.008 45);
--card: oklch(99% 0.002 67); --card: oklch(100% 0 0);
--card-foreground: var(--foreground); --card-foreground: var(--foreground);
--popover: oklch(100% 0 0); --popover: oklch(100% 0 0);
--popover-foreground: var(--foreground); --popover-foreground: var(--foreground);
@@ -36,7 +36,7 @@
--destructive: oklch(55% 0.22 27); --destructive: oklch(55% 0.22 27);
--destructive-foreground: oklch(98% 0.005 30); --destructive-foreground: oklch(98% 0.005 30);
--border: oklch(90.274% 0.01362 60.342); --border: oklch(87.356% 0.01221 67.486);
--input: var(--border); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
@@ -57,10 +57,6 @@
--data-4: oklch(74% 0.18 55); /* âmbar */ --data-4: oklch(74% 0.18 55); /* âmbar */
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */ --data-5: oklch(78% 0.16 68); /* âmbar-dourado */
--data-6: oklch(76% 0.15 82); /* amarelo-quente */ --data-6: oklch(76% 0.15 82); /* amarelo-quente */
--data-7: oklch(70% 0.17 95); /* amarelo-lima */
--data-8: oklch(65% 0.18 108); /* lima-verde */
--data-9: oklch(62% 0.17 120); /* verde-oliva claro */
--data-10: oklch(56% 0.15 10); /* terracota escuro */
--sidebar: oklch(99.3% 0.0015 75); --sidebar: oklch(99.3% 0.0015 75);
--sidebar-foreground: var(--foreground); --sidebar-foreground: var(--foreground);
@@ -71,7 +67,7 @@
--sidebar-border: oklch(91% 0.004 70); --sidebar-border: oklch(91% 0.004 70);
--sidebar-ring: var(--primary); --sidebar-ring: var(--primary);
--radius: 0.625rem; --radius: 0.7rem;
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04); --shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06); --shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
@@ -94,7 +90,7 @@
.dark { .dark {
--background: oklch(18% 0.004 55); --background: oklch(18% 0.004 55);
--foreground: oklch(93% 0.008 80); --foreground: oklch(93% 0.008 80);
--card: oklch(21.5% 0.004 55); --card: oklch(21.531% 0.00369 48.293);
--card-foreground: var(--foreground); --card-foreground: var(--foreground);
--popover: oklch(24% 0.004 55); --popover: oklch(24% 0.004 55);
--popover-foreground: var(--foreground); --popover-foreground: var(--foreground);
@@ -120,7 +116,7 @@
--destructive: oklch(62% 0.2 28); --destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30); --destructive-foreground: oklch(98% 0.005 30);
--border: oklch(31% 0.004 55); --border: oklch(24.957% 0.00355 48.274);
--input: var(--border); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
@@ -141,10 +137,6 @@
--data-4: oklch(81% 0.18 55); --data-4: oklch(81% 0.18 55);
--data-5: oklch(84% 0.16 68); --data-5: oklch(84% 0.16 68);
--data-6: oklch(82% 0.15 82); --data-6: oklch(82% 0.15 82);
--data-7: oklch(77% 0.17 95);
--data-8: oklch(72% 0.18 108);
--data-9: oklch(69% 0.17 120);
--data-10: oklch(63% 0.15 10);
--sidebar: oklch(15.5% 0.004 55); --sidebar: oklch(15.5% 0.004 55);
--sidebar-foreground: var(--foreground); --sidebar-foreground: var(--foreground);
@@ -155,7 +147,7 @@
--sidebar-border: oklch(30% 0.004 55); --sidebar-border: oklch(30% 0.004 55);
--sidebar-ring: var(--primary); --sidebar-ring: var(--primary);
--radius: 0.625rem; --radius: 0.7rem;
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3); --shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4); --shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
@@ -354,3 +346,22 @@
justify-content: flex-end; justify-content: flex-end;
animation: blink-out 6s ease-in-out infinite; animation: blink-out 6s ease-in-out infinite;
} }
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 10s infinite alternate ease-in-out;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}

View File

@@ -9,7 +9,7 @@ import { inter } from "@/public/fonts/font_index";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "OpenMonetis | Suas finanças, do seu jeito", default: "OpenMonetis | Suas finanças, do seu jeito",
template: "%s | OpenMonetis", template: "OpenMonetis | %s",
}, },
description: description:
"Controle suas finanças pessoais de forma simples e transparente.", "Controle suas finanças pessoais de forma simples e transparente.",
@@ -40,7 +40,7 @@ export default function RootLayout({
/> />
)} )}
</head> </head>
<body className="subpixel-antialiased" suppressHydrationWarning> <body className="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light"> <ThemeProvider attribute="class" defaultTheme="light">
<QueryProvider> <QueryProvider>
<Suspense>{children}</Suspense> <Suspense>{children}</Suspense>

View File

@@ -32,57 +32,69 @@ export const user = pgTable("user", {
}).notNull(), }).notNull(),
}); });
export const account = pgTable("account", { export const account = pgTable(
id: text("id").primaryKey(), "account",
accountId: text("accountId").notNull(), {
providerId: text("providerId").notNull(), id: text("id").primaryKey(),
userId: text("userId") accountId: text("accountId").notNull(),
.notNull() providerId: text("providerId").notNull(),
.references(() => user.id, { onDelete: "cascade" }), userId: text("userId")
accessToken: text("accessToken"), .notNull()
refreshToken: text("refreshToken"), .references(() => user.id, { onDelete: "cascade" }),
idToken: text("idToken"), accessToken: text("accessToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", { refreshToken: text("refreshToken"),
mode: "date", idToken: text("idToken"),
withTimezone: true, accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
mode: "date",
withTimezone: true,
}),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
mode: "date",
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
},
(table) => ({
userIdIdx: index("account_user_id_idx").on(table.userId),
}), }),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", { );
mode: "date",
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
});
export const session = pgTable("session", { export const session = pgTable(
id: text("id").primaryKey(), "session",
expiresAt: timestamp("expiresAt", { {
mode: "date", id: text("id").primaryKey(),
withTimezone: true, expiresAt: timestamp("expiresAt", {
}).notNull(), mode: "date",
token: text("token").notNull().unique(), withTimezone: true,
createdAt: timestamp("createdAt", { }).notNull(),
mode: "date", token: text("token").notNull().unique(),
withTimezone: true, createdAt: timestamp("createdAt", {
}).notNull(), mode: "date",
updatedAt: timestamp("updatedAt", { withTimezone: true,
mode: "date", }).notNull(),
withTimezone: true, updatedAt: timestamp("updatedAt", {
}).notNull(), mode: "date",
ipAddress: text("ipAddress"), withTimezone: true,
userAgent: text("userAgent"), }).notNull(),
userId: text("userId") ipAddress: text("ipAddress"),
.notNull() userAgent: text("userAgent"),
.references(() => user.id, { onDelete: "cascade" }), userId: text("userId")
}); .notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("session_user_id_idx").on(table.userId),
}),
);
export const verification = pgTable("verification", { export const verification = pgTable("verification", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -104,24 +116,30 @@ export const verification = pgTable("verification", {
// ===================== PASSKEY (WebAuthn) ===================== // ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", { export const passkey = pgTable(
id: text("id").primaryKey(), "passkey",
name: text("name"), {
publicKey: text("publicKey").notNull(), id: text("id").primaryKey(),
userId: text("userId") name: text("name"),
.notNull() publicKey: text("publicKey").notNull(),
.references(() => user.id, { onDelete: "cascade" }), userId: text("userId")
credentialID: text("credentialID").notNull(), .notNull()
counter: integer("counter").notNull(), .references(() => user.id, { onDelete: "cascade" }),
deviceType: text("deviceType").notNull(), credentialID: text("credentialID").notNull(),
backedUp: boolean("backedUp").notNull(), counter: integer("counter").notNull(),
transports: text("transports"), deviceType: text("deviceType").notNull(),
aaguid: text("aaguid"), backedUp: boolean("backedUp").notNull(),
createdAt: timestamp("createdAt", { transports: text("transports"),
mode: "date", aaguid: text("aaguid"),
withTimezone: true, createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
},
(table) => ({
userIdIdx: index("passkey_user_id_idx").on(table.userId),
}), }),
}); );
export const userPreferences = pgTable("preferencias_usuario", { export const userPreferences = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
@@ -157,39 +175,30 @@ export const userPreferences = pgTable("preferencias_usuario", {
// ===================== PUBLIC TABLES ===================== // ===================== PUBLIC TABLES =====================
export const financialAccounts = pgTable( export const financialAccounts = pgTable("contas", {
"contas", id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
{ name: text("nome").notNull(),
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), accountType: text("tipo_conta").notNull(),
name: text("nome").notNull(), note: text("anotacao"),
accountType: text("tipo_conta").notNull(), status: text("status").notNull(),
note: text("anotacao"), logo: text("logo").notNull(),
status: text("status").notNull(), initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
logo: text("logo").notNull(), .notNull()
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 }) .default("0"),
.notNull() excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
.default("0"), excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false), .notNull()
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas") .default(false),
.notNull() userId: text("user_id")
.default(false), .notNull()
userId: text("user_id") .references(() => user.id, { onDelete: "cascade" }),
.notNull() createdAt: timestamp("created_at", {
.references(() => user.id, { onDelete: "cascade" }), mode: "date",
createdAt: timestamp("created_at", { withTimezone: true,
mode: "date", })
withTimezone: true, .notNull()
}) .defaultNow(),
.notNull() });
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const categories = pgTable( export const categories = pgTable(
"categorias", "categorias",
@@ -227,9 +236,7 @@ export const payers = pgTable(
note: text("anotacao"), note: text("anotacao"),
role: text("role"), role: text("role"),
isAutoSend: boolean("is_auto_send").notNull().default(false), isAutoSend: boolean("is_auto_send").notNull().default(false),
shareCode: text("share_code") shareCode: text("share_code").notNull(),
.notNull()
.default(sql`substr(encode(gen_random_bytes(24), 'base64'), 1, 24)`),
lastMailAt: timestamp("last_mail", { lastMailAt: timestamp("last_mail", {
mode: "date", mode: "date",
withTimezone: true, withTimezone: true,
@@ -248,14 +255,6 @@ export const payers = pgTable(
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on( uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
table.shareCode, table.shareCode,
), ),
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
table.userId,
table.status,
),
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
table.userId,
table.role,
),
}), }),
); );
@@ -285,6 +284,12 @@ export const payerShares = pgTable(
table.payerId, table.payerId,
table.sharedWithUserId, table.sharedWithUserId,
), ),
sharedWithUserIdIdx: index(
"compartilhamentos_pagador_shared_with_user_id_idx",
).on(table.sharedWithUserId),
createdByUserIdIdx: index(
"compartilhamentos_pagador_created_by_user_id_idx",
).on(table.createdByUserId),
}), }),
); );
@@ -296,7 +301,9 @@ export const cards = pgTable(
closingDay: text("dt_fechamento").notNull(), closingDay: text("dt_fechamento").notNull(),
dueDay: text("dt_vencimento").notNull(), dueDay: text("dt_vencimento").notNull(),
note: text("anotacao"), note: text("anotacao"),
limit: numeric("limite", { precision: 10, scale: 2 }), limit: numeric("limite", { precision: 10, scale: 2 })
.notNull()
.default("0"),
brand: text("bandeira"), brand: text("bandeira"),
logo: text("logo"), logo: text("logo"),
status: text("status").notNull(), status: text("status").notNull(),
@@ -317,10 +324,7 @@ export const cards = pgTable(
}), }),
}, },
(table) => ({ (table) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on( accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
table.userId,
table.status,
),
}), }),
); );
@@ -387,26 +391,33 @@ export const budgets = pgTable(
userIdCategoryIdPeriodUnique: uniqueIndex( userIdCategoryIdPeriodUnique: uniqueIndex(
"orcamentos_user_id_categoria_id_periodo_key", "orcamentos_user_id_categoria_id_periodo_key",
).on(table.userId, table.categoryId, table.period), ).on(table.userId, table.categoryId, table.period),
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
}), }),
); );
export const notes = pgTable("anotacoes", { export const notes = pgTable(
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), "anotacoes",
title: text("titulo"), {
description: text("descricao"), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa" title: text("titulo"),
tasks: text("tasks"), // JSON stringificado com array de tarefas description: text("descricao"),
archived: boolean("arquivada").notNull().default(false), type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
createdAt: timestamp("created_at", { tasks: text("tasks"), // JSON stringificado com array de tarefas
mode: "date", archived: boolean("arquivada").notNull().default(false),
withTimezone: true, createdAt: timestamp("created_at", {
}) mode: "date",
.notNull() withTimezone: true,
.defaultNow(), })
userId: text("user_id") .notNull()
.notNull() .defaultNow(),
.references(() => user.id, { onDelete: "cascade" }), userId: text("user_id")
}); .notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
}),
);
export const savedInsights = pgTable( export const savedInsights = pgTable(
"insights_salvos", "insights_salvos",
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash), tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
}), }),
); );
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
table.userId, table.userId,
table.createdAt, table.createdAt,
), ),
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
table.transactionId,
),
}), }),
); );
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
userIdNotificationKeyUnique: uniqueIndex( userIdNotificationKeyUnique: uniqueIndex(
"dashboard_notification_states_user_id_key_unique", "dashboard_notification_states_user_id_key_unique",
).on(table.userId, table.notificationKey), ).on(table.userId, table.notificationKey),
userIdArchivedAtIdx: index(
"dashboard_notification_states_user_id_archived_idx",
).on(table.userId, table.archivedAt),
}), }),
); );
@@ -597,10 +607,14 @@ export const installmentAnticipations = pgTable(
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
table.seriesId,
),
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId), userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
transactionIdIdx: index("antecipacoes_parcelas_lancamento_id_idx").on(
table.transactionId,
),
payerIdIdx: index("antecipacoes_parcelas_pagador_id_idx").on(table.payerId),
categoryIdIdx: index("antecipacoes_parcelas_categoria_id_idx").on(
table.categoryId,
),
}), }),
); );
@@ -656,6 +670,7 @@ export const transactions = pgTable(
onUpdate: "cascade", onUpdate: "cascade",
}), }),
seriesId: uuid("series_id"), seriesId: uuid("series_id"),
splitGroupId: uuid("split_group_id"),
transferId: uuid("transfer_id"), transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"), ofxFitId: text("ofx_fit_id"),
importBatchId: text("import_batch_id"), importBatchId: text("import_batch_id"),
@@ -688,6 +703,11 @@ export const transactions = pgTable(
), ),
// Índice para buscar parcelas de uma série // Índice para buscar parcelas de uma série
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId), seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
// Índice para buscar shares de um split (userId + splitGroupId)
userIdSplitGroupIdIdx: index("lancamentos_user_id_split_group_id_idx").on(
table.userId,
table.splitGroupId,
),
// Índice para buscar transferências relacionadas // Índice para buscar transferências relacionadas
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId), transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
// Índice para filtrar por condição (aberto, realizado, cancelado) // Índice para filtrar por condição (aberto, realizado, cancelado)
@@ -700,6 +720,12 @@ export const transactions = pgTable(
table.cardId, table.cardId,
table.period, table.period,
), ),
// FK indexes: evitam seq scan em deletes/updates nas tabelas pai
accountIdIdx: index("lancamentos_conta_id_idx").on(table.accountId),
categoryIdIdx: index("lancamentos_categoria_id_idx").on(table.categoryId),
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
table.anticipationId,
),
// Dedup OFX: garante FITID único por usuário // Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx") ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId) .on(table.userId, table.ofxFitId)
@@ -905,19 +931,25 @@ export const installmentAnticipationsRelations = relations(
// ===================== ATTACHMENTS ===================== // ===================== ATTACHMENTS =====================
export const attachments = pgTable("anexos", { export const attachments = pgTable(
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), "anexos",
userId: text("user_id") {
.notNull() id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
.references(() => user.id, { onDelete: "cascade" }), userId: text("user_id")
fileKey: text("chave_arquivo").notNull().unique(), .notNull()
fileName: text("nome_arquivo").notNull(), .references(() => user.id, { onDelete: "cascade" }),
fileSize: integer("tamanho_bytes").notNull(), fileKey: text("chave_arquivo").notNull().unique(),
mimeType: text("mime_type").notNull(), fileName: text("nome_arquivo").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) fileSize: integer("tamanho_bytes").notNull(),
.notNull() mimeType: text("mime_type").notNull(),
.defaultNow(), createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
}); .notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index("anexos_user_id_idx").on(table.userId),
}),
);
export const transactionAttachments = pgTable( export const transactionAttachments = pgTable(
"lancamento_anexos", "lancamento_anexos",
@@ -953,6 +985,9 @@ export const importCategoryMappings = pgTable(
}, },
(table) => ({ (table) => ({
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }), pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
table.categoryId,
),
}), }),
); );

View File

@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { categories, financialAccounts, transactions } from "@/db/schema"; import { categories, financialAccounts, transactions } from "@/db/schema";
import { import {
ACCOUNT_BALANCE_ADJUSTMENT_NAME,
INITIAL_BALANCE_CATEGORY_NAME, INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION, INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
@@ -17,6 +18,7 @@ import {
} from "@/shared/lib/actions/helpers"; } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common"; import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import { import {
@@ -26,8 +28,11 @@ import {
TRANSFER_ESTABLISHMENT_SAIDA, TRANSFER_ESTABLISHMENT_SAIDA,
TRANSFER_PAYMENT_METHOD, TRANSFER_PAYMENT_METHOD,
} from "@/shared/lib/transfers/constants"; } from "@/shared/lib/transfers/constants";
import { formatDecimalForDbRequired } from "@/shared/utils/currency"; import {
import { getTodayInfo } from "@/shared/utils/date"; formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
import { normalizeFilePath } from "@/shared/utils/string"; import { normalizeFilePath } from "@/shared/utils/string";
const accountBaseSchema = z.object({ const accountBaseSchema = z.object({
@@ -99,7 +104,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) { if (hasInitialBalance && !adminPayerId) {
throw new Error( throw new Error(
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.", "Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de definir um saldo inicial.",
); );
} }
@@ -299,7 +304,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) { if (!adminPayerId) {
throw new Error( throw new Error(
"Pagador administrador não encontrado. Por favor, crie um pagador admin.", "Pessoa administrador não encontrada. Por favor, crie uma pessoa admin.",
); );
} }
@@ -391,3 +396,120 @@ export async function transferBetweenAccountsAction(
return handleActionError(error); return handleActionError(error);
} }
} }
const adjustAccountBalanceSchema = z.object({
accountId: uuidSchema("FinancialAccount"),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
currentBalance: z.number({ message: "Saldo atual inválido." }),
targetBalance: z.number({ message: "Saldo correto inválido." }),
});
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
export async function adjustAccountBalanceAction(
input: AdjustAccountBalanceInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = adjustAccountBalanceSchema.parse(input);
const adminPayerId = await getAdminPayerId(user.id);
if (!adminPayerId) {
throw new Error(
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de ajustar o saldo.",
);
}
let message = "Ajuste de saldo registrado.";
await db.transaction(async (tx: typeof db) => {
const account = await tx.query.financialAccounts.findFirst({
columns: { id: true },
where: and(
eq(financialAccounts.id, data.accountId),
eq(financialAccounts.userId, user.id),
),
});
if (!account) {
throw new Error("Conta não encontrada.");
}
const existing = await tx.query.transactions.findFirst({
columns: { id: true, amount: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.accountId, data.accountId),
eq(transactions.period, data.period),
eq(transactions.name, ACCOUNT_BALANCE_ADJUSTMENT_NAME),
),
});
const existingAmount = Number(existing?.amount ?? 0);
const baseBalance = data.currentBalance - existingAmount;
const adjustmentAmount =
Math.round((data.targetBalance - baseBalance) * 100) / 100;
if (adjustmentAmount === 0) {
if (existing) {
await tx.delete(transactions).where(eq(transactions.id, existing.id));
message = "Ajuste de saldo removido.";
} else {
message = "Nada a ajustar — o saldo já está correto.";
}
return;
}
const isExpense = adjustmentAmount < 0;
const categoryName = isExpense ? "Outras despesas" : "Outras receitas";
const category = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, categoryName),
),
});
const amount = formatDecimalForDbRequired(adjustmentAmount);
const note = `O saldo era ${formatCurrency(baseBalance)} mas o correto é ${formatCurrency(data.targetBalance)}.`;
const payload = {
condition: INITIAL_BALANCE_CONDITION,
name: ACCOUNT_BALANCE_ADJUSTMENT_NAME,
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
note,
amount,
purchaseDate: getBusinessTodayDate(),
transactionType: isExpense
? ("Despesa" as const)
: ("Receita" as const),
period: data.period,
isSettled: true,
userId: user.id,
accountId: data.accountId,
cardId: null,
categoryId: category?.id ?? null,
payerId: adminPayerId,
};
if (existing) {
await tx
.update(transactions)
.set(payload)
.where(eq(transactions.id, existing.id));
} else {
await tx.insert(transactions).values(payload);
}
});
revalidateForEntity("accounts", user.id);
revalidateForEntity("transactions", user.id);
return { success: true, message };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { import {
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiDeleteBin5Line, RiDeleteBin5Line,
@@ -47,6 +48,13 @@ export function AccountCard({
}: AccountCardProps) { }: AccountCardProps) {
const isInactive = status?.toLowerCase() === "inativa"; const isInactive = status?.toLowerCase() === "inativa";
const balanceColor =
balance > 0
? "text-success"
: balance < 0
? "text-destructive"
: "text-foreground";
const actions = [ const actions = [
{ {
label: "editar", label: "editar",
@@ -75,78 +83,90 @@ export function AccountCard({
].filter((action) => typeof action.onClick === "function"); ].filter((action) => typeof action.onClick === "function");
return ( return (
<Card className={cn("h-full w-full gap-0", className)}> <Card className={cn("flex w-full flex-col p-6", className)}>
<CardContent className="flex flex-1 flex-col gap-4"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{icon ? ( <div
<div className={cn(
className={cn( "flex shrink-0 items-center justify-center",
"flex items-center justify-center", isInactive && "grayscale opacity-40",
isInactive && "[&_img]:grayscale [&_img]:opacity-40", )}
)} >
> {icon}
{icon} </div>
<div className="min-w-0">
<div className="flex items-center gap-1">
<h3 className="truncate font-semibold text-foreground">
{accountName}
</h3>
{excludeFromBalance || excludeInitialBalanceFromIncome ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 text-muted-foreground/70 transition-colors hover:text-foreground"
aria-label="Informações da conta"
>
<RiInformationLine className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-xs">
<div className="space-y-1">
{excludeFromBalance && (
<p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta
conta não é incluída no cálculo do saldo total geral.
</p>
)}
{excludeInitialBalanceFromIncome && (
<p className="text-xs">
<strong>
Saldo inicial desconsiderado das receitas:
</strong>{" "}
O saldo inicial desta conta não é contabilizado como
receita nas métricas.
</p>
)}
</div>
</TooltipContent>
</Tooltip>
) : null}
</div> </div>
) : null}
<h2 className="text-lg font-semibold text-foreground">
{accountName}
</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && ( <p className="text-xs text-muted-foreground">{status}</p>
<Tooltip> </div>
<TooltipTrigger asChild>
<div className="flex items-center">
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-1">
{excludeFromBalance && (
<p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta
não é incluída no cálculo do saldo total geral.
</p>
)}
{excludeInitialBalanceFromIncome && (
<p className="text-xs">
<strong>
Saldo inicial desconsiderado das receitas:
</strong>{" "}
O saldo inicial desta conta não é contabilizado como
receita nas métricas.
</p>
)}
</div>
</TooltipContent>
</Tooltip>
)}
</div> </div>
<div className="space-y-2"> <p className="text-xs text-muted-foreground">{accountType}</p>
<MoneyValues amount={balance} className="text-3xl" /> </div>
<p className="text-sm text-muted-foreground">{accountType}</p>
<CardContent className="flex flex-1 flex-col gap-2 px-0 pb-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Saldo</span>
<MoneyValues
amount={balance}
className={cn("text-2xl font-semibold", balanceColor)}
/>
</div> </div>
</CardContent> </CardContent>
{actions.length > 0 ? ( <CardFooter className="flex flex-wrap gap-4 p-0 text-sm">
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm"> {actions.map(({ label, icon, onClick, variant }) => (
{actions.map(({ label, icon, onClick, variant }) => ( <button
<button key={label}
key={label} type="button"
type="button" onClick={onClick}
onClick={onClick} className={cn(
className={cn( "flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80", variant === "destructive" ? "text-destructive" : "text-primary",
variant === "destructive" ? "text-destructive" : "text-primary", )}
)} aria-label={`${label} conta`}
aria-label={`${label} conta`} >
> {icon}
{icon} {label}
{label} </button>
</button> ))}
))} </CardFooter>
</CardFooter>
) : null}
</Card> </Card>
); );
} }

View File

@@ -227,12 +227,12 @@ export function AccountDialog({
}); });
}; };
const title = mode === "create" ? "Nova conta" : "Editar conta"; const title = mode === "create" ? "Nova conta" : "Atualizar conta";
const description = const description =
mode === "create" mode === "create"
? "Cadastre uma nova conta para organizar seus lançamentos." ? "Cadastre uma nova conta para organizar seus lançamentos."
: "Atualize as informações da conta selecionada."; : "Atualize as informações da conta selecionada.";
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta"; const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
const handleMainDialogOpenChange = (open: boolean) => { const handleMainDialogOpenChange = (open: boolean) => {
if (!open && logoDialogOpen) { if (!open && logoDialogOpen) {

View File

@@ -26,6 +26,7 @@ type AccountStatementCardProps = {
totalExpenses: number; totalExpenses: number;
logo?: string | null; logo?: string | null;
actions?: React.ReactNode; actions?: React.ReactNode;
balanceAdjustment?: React.ReactNode;
}; };
const getAccountStatusBadgeVariant = ( const getAccountStatusBadgeVariant = (
@@ -45,6 +46,7 @@ export function AccountStatementCard({
totalExpenses, totalExpenses,
logo, logo,
actions, actions,
balanceAdjustment,
}: AccountStatementCardProps) { }: AccountStatementCardProps) {
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const resultado = totalIncomes - totalExpenses; const resultado = totalIncomes - totalExpenses;
@@ -84,10 +86,13 @@ export function AccountStatementCard({
<p className="text-sm text-muted-foreground "> <p className="text-sm text-muted-foreground ">
Saldo ao final do período Saldo ao final do período
</p> </p>
<MoneyValues <div className="flex items-center gap-2">
amount={currentBalance} <MoneyValues
className="text-3xl leading-none tracking-tighter sm:text-[2rem]" amount={currentBalance}
/> className="text-3xl leading-none tracking-tighter sm:text-2xl"
/>
{balanceAdjustment}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={getAccountStatusBadgeVariant(status)} variant={getAccountStatusBadgeVariant(status)}
@@ -123,7 +128,7 @@ export function AccountStatementCard({
<MetaItem <MetaItem
label="Saídas" label="Saídas"
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)." tooltip="Total de despesas pagas neste mês (considerando divisão entre pessoas)."
> >
<span className="text-sm font-medium text-destructive"> <span className="text-sm font-medium text-destructive">
{formatCurrency(totalExpenses)} {formatCurrency(totalExpenses)}

View File

@@ -212,7 +212,7 @@ export function AccountsPage({
onOpenChange={handleRemoveOpenChange} onOpenChange={handleRemoveOpenChange}
title={removeTitle} title={removeTitle}
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos." description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
confirmLabel="Remover conta" confirmLabel="Remover"
pendingLabel="Removendo..." pendingLabel="Removendo..."
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}

View File

@@ -0,0 +1,135 @@
"use client";
import { RiEqualizerLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { adjustAccountBalanceAction } from "@/features/accounts/actions";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
type AdjustBalanceDialogProps = {
accountId: string;
period: string;
currentBalance: number;
};
export function AdjustBalanceDialog({
accountId,
period,
currentBalance,
}: AdjustBalanceDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [amount, setAmount] = useState<string>(currentBalance.toFixed(2));
useEffect(() => {
if (open) {
setAmount(currentBalance.toFixed(2));
}
}, [open, currentBalance]);
const targetBalance = Number(amount);
const diff = Number.isFinite(targetBalance)
? Math.round((targetBalance - currentBalance) * 100) / 100
: 0;
const diffLabel =
diff > 0
? `Será criado um lançamento de receita de ${formatCurrency(diff)}.`
: diff < 0
? `Será criado um lançamento de despesa de ${formatCurrency(Math.abs(diff))}.`
: "Nenhum ajuste será criado — o saldo já está correto.";
const handleSave = () => {
if (!Number.isFinite(targetBalance)) {
toast.error("Informe um valor válido.");
return;
}
startTransition(async () => {
const result = await adjustAccountBalanceAction({
accountId,
period,
currentBalance,
targetBalance,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Ajustar saldo"
>
<RiEqualizerLine className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajustar saldo</DialogTitle>
<DialogDescription>
Informe o saldo correto da conta ao final do período. A diferença em
relação ao saldo atual será lançada como um ajuste.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="text-muted-foreground">Saldo atual no sistema</p>
<p className="font-medium text-foreground">
{formatCurrency(currentBalance)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="adjust-balance-target">Saldo correto</Label>
<CurrencyInput
id="adjust-balance-target"
value={amount}
onValueChange={setAmount}
autoFocus
/>
<p className="text-xs text-muted-foreground">{diffLabel}</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,11 +4,14 @@ import {
fetchTransactionsPageWithRelations, fetchTransactionsPageWithRelations,
fetchTransactionsWithRelations, fetchTransactionsWithRelations,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import {
INITIAL_BALANCE_NOTE,
REFUND_NOTE_PREFIX,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AccountSummaryData = { type AccountSummaryData = {
openingBalance: number; openingBalance: number;
currentBalance: number; currentBalance: number;
totalIncomes: number; totalIncomes: number;
@@ -74,7 +77,9 @@ export async function fetchAccountSummary(
sum( sum(
case case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then 0
when ${transactions.transactionType} = 'Receita' then ${transactions.amount} when ${transactions.transactionType} = 'Receita' then ${transactions.amount}
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} > 0 then ${transactions.amount}
else 0 else 0
end end
), ),
@@ -86,7 +91,9 @@ export async function fetchAccountSummary(
sum( sum(
case case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then abs(${transactions.amount})
when ${transactions.transactionType} = 'Despesa' then ${transactions.amount} when ${transactions.transactionType} = 'Despesa' then ${transactions.amount}
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} < 0 then ${transactions.amount}
else 0 else 0
end end
), ),
@@ -135,7 +142,8 @@ export async function fetchAccountSummary(
const openingBalance = initialBalance + previousMovements; const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0); const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 0); const totalIncomes = Number(periodSummary?.incomes ?? 0);
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); const expenseNet = Number(periodSummary?.expenses ?? 0);
const totalExpenses = Math.max(0, -expenseNet);
const currentBalance = openingBalance + netAmount; const currentBalance = openingBalance + netAmount;
return { return {

View File

@@ -1,25 +1,12 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import AuthSidebar from "./auth-sidebar"; import AuthSidebar from "./auth-sidebar";
export function AuthCardShell({ children }: PropsWithChildren) { export function AuthCardShell({ children }: PropsWithChildren) {
return ( return (
<Card className="relative overflow-hidden p-0"> <Card className="overflow-hidden border-primary/10 p-0 shadow-lg">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"> <CardContent className="grid p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
<DotPattern <div className="flex md:rounded-l-4xl">{children}</div>
width={17}
height={17}
cx={1.3}
cy={1.3}
cr={1.3}
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
/>
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent" />
</div>
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
<div className="flex bg-card/92 backdrop-blur-[1px]">{children}</div>
<AuthSidebar /> <AuthSidebar />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -15,7 +15,6 @@ import type { Budget } from "./types";
interface BudgetCardProps { interface BudgetCardProps {
budget: Budget; budget: Budget;
periodLabel: string;
onEdit: (budget: Budget) => void; onEdit: (budget: Budget) => void;
onRemove: (budget: Budget) => void; onRemove: (budget: Budget) => void;
} }
@@ -29,81 +28,88 @@ const buildUsagePercent = (spent: number, limit: number) => {
}; };
const formatCategoryName = (budget: Budget) => const formatCategoryName = (budget: Budget) =>
budget.category?.name ?? "Category removida"; budget.category?.name ?? "Categoria removida";
export function BudgetCard({ export function BudgetCard({ budget, onEdit, onRemove }: BudgetCardProps) {
budget,
periodLabel,
onEdit,
onRemove,
}: BudgetCardProps) {
const { amount: limit, spent } = budget; const { amount: limit, spent } = budget;
const exceeded = spent > limit && limit >= 0; const exceeded = spent > limit && limit >= 0;
const difference = Math.abs(spent - limit); const difference = Math.abs(spent - limit);
const usagePercent = buildUsagePercent(spent, limit); const usagePercent = buildUsagePercent(spent, limit);
const remaining = Math.max(limit - spent, 0);
return ( return (
<Card className="flex h-full flex-col"> <Card className="flex w-full flex-col p-6">
<CardContent className="flex h-full flex-col gap-4"> <div className="flex items-center gap-2">
<div className="flex items-start gap-3"> <CategoryIconBadge
<CategoryIconBadge icon={budget.category?.icon ?? undefined}
icon={budget.category?.icon ?? undefined} name={formatCategoryName(budget)}
name={formatCategoryName(budget)} size="lg"
size="lg" />
<div className="min-w-0">
<h3 className="truncate font-semibold text-foreground">
{formatCategoryName(budget)}
</h3>
</div>
</div>
<CardContent className="flex flex-1 flex-col gap-4 p-0">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
{exceeded ? "Excedido em" : "Disponível"}
</span>
<MoneyValues
amount={exceeded ? difference : remaining}
className={cn(
"text-xl font-semibold",
exceeded ? "text-destructive" : "text-success",
)}
/> />
<div className="space-y-1">
<h3 className="text-base font-semibold leading-tight">
{formatCategoryName(budget)}
</h3>
<p className="text-xs text-muted-foreground">
Orçamento de {periodLabel}
</p>
</div>
</div> </div>
<div className="flex flex-1 flex-col gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="flex items-baseline justify-between text-sm"> <div className="flex flex-col gap-0.5">
<span className="text-muted-foreground">Gasto até agora</span> <span className="text-xs text-muted-foreground">Orçamento</span>
<MoneyValues <MoneyValues
amount={spent} amount={limit}
className={cn(exceeded && "text-destructive")} className="text-sm font-semibold text-foreground"
/> />
</div> </div>
<Progress <div className="flex flex-col gap-0.5">
value={usagePercent} <span className="text-xs text-muted-foreground">Gasto</span>
className={cn("h-2", exceeded && "bg-destructive/20!")} <MoneyValues
/> amount={spent}
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm"> className={cn(
<span className="text-muted-foreground">Limite</span> "text-sm font-semibold",
<MoneyValues amount={limit} className="text-foreground" /> exceeded ? "text-destructive" : "text-primary",
</div> )}
/>
<div>
{exceeded ? (
<div className="text-xs text-destructive">
Excedeu em <MoneyValues amount={difference} />
</div>
) : (
<div className="text-xs text-success">
Restam <MoneyValues amount={Math.max(limit - spent, 0)} />{" "}
disponíveis.
</div>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className={cn("h-2.5", exceeded && "bg-destructive/20!")}
aria-label={`${usagePercent.toFixed(1)}% do orçamento utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</CardContent> </CardContent>
<CardFooter className="flex flex-wrap gap-3 px-5 text-sm">
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
<button <button
type="button" type="button"
onClick={() => onEdit(budget)} onClick={() => onEdit(budget)}
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
> >
<RiPencilLine className="size-4" aria-hidden /> editar <RiPencilLine className="size-4" aria-hidden /> editar
</button> </button>
{budget.category && ( {budget.category && (
<Link <Link
href={`/categories/${budget.category.id}`} href={`/categories/${budget.category.id}`}
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
> >
<RiFileList2Line className="size-4" aria-hidden /> detalhes <RiFileList2Line className="size-4" aria-hidden /> detalhes
</Link> </Link>
@@ -111,7 +117,7 @@ export function BudgetCard({
<button <button
type="button" type="button"
onClick={() => onRemove(budget)} onClick={() => onRemove(budget)}
className="flex items-center gap-1 text-destructive font-medium transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
> >
<RiDeleteBin5Line className="size-4" aria-hidden /> remover <RiDeleteBin5Line className="size-4" aria-hidden /> remover
</button> </button>

View File

@@ -161,13 +161,12 @@ export function BudgetDialog({
}); });
}; };
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento"; const title = mode === "create" ? "Novo orçamento" : "Atualizar orçamento";
const description = const description =
mode === "create" mode === "create"
? "Defina um limite de gastos para acompanhar suas despesas." ? "Defina um limite de gastos para acompanhar suas despesas."
: "Atualize os detalhes do orçamento selecionado."; : "Atualize os detalhes do orçamento selecionado.";
const submitLabel = const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
const disabled = categories.length === 0; const disabled = categories.length === 0;
const parsedAmount = Number.parseFloat(formState.amount); const parsedAmount = Number.parseFloat(formState.amount);
const sliderValue = Number.isFinite(parsedAmount) const sliderValue = Number.isFinite(parsedAmount)

View File

@@ -19,14 +19,12 @@ interface BudgetsPageProps {
budgets: Budget[]; budgets: Budget[];
categories: BudgetCategory[]; categories: BudgetCategory[];
selectedPeriod: string; selectedPeriod: string;
periodLabel: string;
} }
export function BudgetsPage({ export function BudgetsPage({
budgets, budgets,
categories, categories,
selectedPeriod, selectedPeriod,
periodLabel,
}: BudgetsPageProps) { }: BudgetsPageProps) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null); const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
@@ -137,7 +135,6 @@ export function BudgetsPage({
<BudgetCard <BudgetCard
key={budget.id} key={budget.id}
budget={budget} budget={budget}
periodLabel={periodLabel}
onEdit={handleEdit} onEdit={handleEdit}
onRemove={handleRemoveRequest} onRemove={handleRemoveRequest}
/> />
@@ -168,7 +165,7 @@ export function BudgetsPage({
onOpenChange={handleRemoveOpenChange} onOpenChange={handleRemoveOpenChange}
title={removeTitle} title={removeTitle}
description="Esta ação remove o limite configurado para a categoria selecionada." description="Esta ação remove o limite configurado para a categoria selecionada."
confirmLabel="Remover orçamento" confirmLabel="Remover"
pendingLabel="Removendo..." pendingLabel="Removendo..."
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}
@@ -179,7 +176,7 @@ export function BudgetsPage({
onOpenChange={setDuplicateOpen} onOpenChange={setDuplicateOpen}
title="Copiar orçamentos do último mês?" title="Copiar orçamentos do último mês?"
description="Isso copiará os limites definidos no mês anterior para as categorias que ainda não possuem orçamento neste mês." description="Isso copiará os limites definidos no mês anterior para as categorias que ainda não possuem orçamento neste mês."
confirmLabel="Copiar orçamentos" confirmLabel="Copiar"
pendingLabel="Copiando..." pendingLabel="Copiando..."
onConfirm={handleDuplicateConfirm} onConfirm={handleDuplicateConfirm}
/> />

View File

@@ -13,7 +13,7 @@ const toNumber = (value: string | number | null | undefined) => {
return 0; return 0;
}; };
export type BudgetData = { type BudgetData = {
id: string; id: string;
amount: number; amount: number;
spent: number; spent: number;

View File

@@ -1,10 +1,8 @@
"use client"; "use client";
import { DayCell } from "@/features/calendar/components/day-cell"; import { DayCell } from "@/features/calendar/components/day-cell";
import type { CalendarDay } from "@/shared/lib/types/calendar"; import type { CalendarDay } from "@/shared/lib/types/calendar";
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar"; import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
import { cn } from "@/shared/utils/ui";
type CalendarGridProps = { type CalendarGridProps = {
days: CalendarDay[]; days: CalendarDay[];
@@ -18,21 +16,18 @@ export function CalendarGrid({
onCreateDay, onCreateDay,
}: CalendarGridProps) { }: CalendarGridProps) {
return ( return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none"> <div className="overflow-hidden">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => ( {WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center"> <span key={dayName} className="text-center">
{dayName} {dayName}
</span> </span>
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px"> <div className="grid grid-cols-7 gap-px px-px pb-px pt-px">
{days.map((day) => ( {days.map((day) => (
<div <div key={day.date} className="h-[150px] p-0.5">
key={day.date}
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
>
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} /> <DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
</div> </div>
))} ))}

View File

@@ -1,34 +1,33 @@
"use client"; "use client";
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell"; import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
import StatusDot from "@/shared/components/status-dot"; import { cn } from "@/shared/utils/ui";
import { Card } from "@/shared/components/ui/card";
import type { CalendarEvent } from "@/shared/lib/types/calendar";
const LEGEND_ITEMS: Array<{ const LEGEND_ITEMS = [
type?: CalendarEvent["type"]; { label: "Lançamentos", ...EVENT_TYPE_STYLES.transaction },
label: string; { label: "Parcelas", ...EVENT_TYPE_STYLES.installment },
dotColor?: string; { label: "Boletos", ...EVENT_TYPE_STYLES.boleto },
}> = [ { label: "Fatura de Cartão", ...EVENT_TYPE_STYLES.card },
{ type: "transaction", label: "Lançamentos" },
{ type: "boleto", label: "Boleto com vencimento" },
{ type: "card", label: "Vencimento de cartão" },
{ label: "Pagamento fatura", dotColor: "bg-success" },
]; ];
export function CalendarLegend() { export function CalendarLegend() {
return ( return (
<Card className="flex flex-row gap-2 p-2 text-sm"> <ul className="flex items-center justify-start gap-2 px-1">
{LEGEND_ITEMS.map((item, index) => { {LEGEND_ITEMS.map((item) => (
const dotColor = <li
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : ""); key={item.label}
return ( className={cn(
<span key={item.type || index} className="flex items-center gap-2"> "flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium",
<StatusDot color={dotColor} /> item.wrapper,
{item.label} )}
</span> >
); <span
})} className={cn("size-1.5 shrink-0 rounded-full", item.dot)}
</Card> aria-hidden
/>
{item.label}
</li>
))}
</ul>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiAddLine } from "@remixicon/react"; import { RiAddLine, RiCheckboxCircleFill } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react"; import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar"; import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
import { currencyFormatter } from "@/shared/utils/currency"; import { currencyFormatter } from "@/shared/utils/currency";
@@ -14,44 +14,40 @@ type DayCellProps = {
export const EVENT_TYPE_STYLES: Record< export const EVENT_TYPE_STYLES: Record<
CalendarEvent["type"], CalendarEvent["type"],
{ wrapper: string; dot: string; accent?: string } { wrapper: string; dot: string }
> = { > = {
transaction: { transaction: {
wrapper: "bg-primary/10 text-primary dark:bg-primary/5 dark:text-primary",
dot: "bg-primary",
},
installment: {
wrapper: wrapper:
"bg-warning/10 text-warning dark:bg-warning/5 dark:text-warning border-l-4 border-warning", "bg-amber-100 text-amber-600 dark:bg-amber-900/10 dark:text-amber-500",
dot: "bg-warning", dot: "bg-amber-500",
}, },
boleto: { boleto: {
wrapper: wrapper: "bg-info/10 text-info dark:bg-info/5 dark:text-info",
"bg-info/10 text-info dark:bg-info/5 dark:text-info border-l-4 border-info",
dot: "bg-info", dot: "bg-info",
}, },
card: { card: {
wrapper: wrapper:
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500", "bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-500",
dot: "bg-violet-600", dot: "bg-violet-600 dark:bg-violet-500",
}, },
}; };
const eventStyles = EVENT_TYPE_STYLES;
const formatCurrencyValue = (value: number | null | undefined) => const formatCurrencyValue = (value: number | null | undefined) =>
currencyFormatter.format(Math.abs(value ?? 0)); currencyFormatter.format(Math.abs(value ?? 0));
const formatAmount = (event: Extract<CalendarEvent, { type: "transaction" }>) =>
formatCurrencyValue(event.transaction.amount);
const buildEventLabel = (event: CalendarEvent) => { const buildEventLabel = (event: CalendarEvent) => {
switch (event.type) { switch (event.type) {
case "transaction": { case "transaction":
case "boleto":
return event.transaction.name; return event.transaction.name;
} case "installment":
case "boleto": {
return event.transaction.name; return event.transaction.name;
} case "card":
case "card": {
return event.card.name; return event.card.name;
}
default: default:
return ""; return "";
} }
@@ -59,60 +55,50 @@ const buildEventLabel = (event: CalendarEvent) => {
const buildEventComplement = (event: CalendarEvent) => { const buildEventComplement = (event: CalendarEvent) => {
switch (event.type) { switch (event.type) {
case "transaction": { case "transaction":
return formatAmount(event); case "boleto":
}
case "boleto": {
return formatCurrencyValue(event.transaction.amount); return formatCurrencyValue(event.transaction.amount);
} case "installment":
case "card": { return `${event.installmentCount}x de ${formatCurrencyValue(event.installmentValue)}`;
if (event.card.totalDue !== null) { case "card":
return formatCurrencyValue(event.card.totalDue); return event.card.totalDue !== null
} ? formatCurrencyValue(event.card.totalDue)
return null; : null;
}
default: default:
return null; return null;
} }
}; };
const isPagamentoFatura = (event: CalendarEvent) => { const isPaid = (event: CalendarEvent) => {
return ( if (event.type === "boleto") return Boolean(event.transaction.isSettled);
event.type === "transaction" && if (event.type === "card") return event.card.isPaid;
event.transaction.name.startsWith("Pagamento fatura -") return false;
);
};
const getEventStyle = (event: CalendarEvent) => {
if (isPagamentoFatura(event)) {
return {
wrapper:
"bg-success/10 text-success dark:bg-success/5 dark:text-success border-l-4 border-success",
dot: "bg-success",
};
}
return eventStyles[event.type];
}; };
const DayEventPreview = ({ event }: { event: CalendarEvent }) => { const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
const complement = buildEventComplement(event); const complement = buildEventComplement(event);
const label = buildEventLabel(event); const label = buildEventLabel(event);
const style = getEventStyle(event); const style = EVENT_TYPE_STYLES[event.type];
return ( return (
<div <div
className={cn( className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs", "flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs",
style.wrapper, style.wrapper,
)} )}
> >
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span
className={cn("size-1.5 shrink-0 rounded-full", style.dot)}
aria-hidden
/>
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
{isPaid(event) && (
<RiCheckboxCircleFill className="size-3.5 shrink-0 text-success" />
)}
</div> </div>
{complement ? ( {complement ? (
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}> <span className="shrink-0 font-medium">{complement}</span>
{complement}
</span>
) : null} ) : null}
</div> </div>
); );
@@ -143,8 +129,8 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
onClick={() => onSelect(day)} onClick={() => onSelect(day)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={cn(
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent", "group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/70 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
!day.isCurrentMonth && "opacity-60", !day.isCurrentMonth && "bg-muted/20 opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary", day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)} )}
> >
@@ -159,14 +145,16 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
> >
{day.label} {day.label}
</span> </span>
<button {day.isCurrentMonth && (
type="button" <button
onClick={handleCreateClick} type="button"
className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-primary/20 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1" onClick={handleCreateClick}
aria-label={`Criar lançamento em ${day.date}`} className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-primary/20 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
> aria-label={`Criar lançamento em ${day.date}`}
<RiAddLine className="size-3.5" /> >
</button> <RiAddLine className="size-3.5" />
</button>
)}
</div> </div>
<div className="flex flex-1 flex-col gap-1.5"> <div className="flex flex-1 flex-col gap-1.5">

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiCalendarEventLine } from "@remixicon/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell"; import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -29,17 +30,13 @@ type EventModalProps = {
const EventCard = ({ const EventCard = ({
children, children,
type, type,
isPagamentoFatura = false,
}: { }: {
children: ReactNode; children: ReactNode;
type: CalendarEvent["type"]; type: CalendarEvent["type"];
isPagamentoFatura?: boolean;
}) => { }) => {
const style = isPagamentoFatura const style = EVENT_TYPE_STYLES[type];
? { dot: "bg-success" }
: EVENT_TYPE_STYLES[type];
return ( return (
<Card className="flex flex-row gap-2 p-3 mb-1"> <Card className="flex flex-row gap-2 p-3">
<span <span
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)} className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
aria-hidden aria-hidden
@@ -49,41 +46,34 @@ const EventCard = ({
); );
}; };
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "2-digit",
year: "numeric",
};
const renderLancamento = ( const renderLancamento = (
event: Extract<CalendarEvent, { type: "transaction" }>, event: Extract<CalendarEvent, { type: "transaction" }>,
) => { ) => {
const isReceita = event.transaction.transactionType === "Receita"; const isReceita = event.transaction.transactionType === "Receita";
const isPagamentoFatura =
event.transaction.name.startsWith("Pagamento fatura -");
return ( return (
<EventCard type="transaction" isPagamentoFatura={isPagamentoFatura}> <EventCard type="transaction">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span <span className="text-sm font-medium leading-tight">
className={`text-sm font-medium leading-tight ${
isPagamentoFatura && "text-success"
}`}
>
{event.transaction.name} {event.transaction.name}
</span> </span>
<Badge variant="outline">{event.transaction.categoriaName}</Badge>
<div className="flex gap-1">
<Badge variant={"outline"}>{event.transaction.categoriaName}</Badge>
</div>
</div> </div>
<span <MoneyValues
showPositiveSign
className={cn( className={cn(
"text-sm font-medium whitespace-nowrap", "text-base whitespace-nowrap font-medium",
isReceita ? "text-success" : "text-foreground", isReceita ? "text-success" : "text-foreground",
)} )}
> amount={event.transaction.amount}
<MoneyValues />
showPositiveSign
className="text-base"
amount={event.transaction.amount}
/>
</span>
</div> </div>
</EventCard> </EventCard>
); );
@@ -91,64 +81,118 @@ const renderLancamento = (
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => { const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const isPaid = Boolean(event.transaction.isSettled); const isPaid = Boolean(event.transaction.isSettled);
const dueDate = event.transaction.dueDate; const dueDateLabel = formatFinancialDateLabel(
const dueDateLabel = formatFinancialDateLabel(dueDate, "Vence em", { event.transaction.dueDate,
day: "2-digit", "Vence em",
month: "2-digit", DATE_FORMAT,
year: "numeric", );
}); const paymentDateLabel = isPaid
? formatFinancialDateLabel(
event.transaction.boletoPaymentDate,
"Pago em",
DATE_FORMAT,
)
: null;
return ( return (
<EventCard type="boleto"> <EventCard type="boleto">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center"> <span className="text-sm font-medium leading-tight">
<span className="text-sm font-medium leading-tight"> {event.transaction.name}
{event.transaction.name} </span>
</span> <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
{dueDateLabel && ( {dueDateLabel && (
<span className="text-xs text-muted-foreground leading-tight"> <span className="text-muted-foreground">{dueDateLabel}</span>
{dueDateLabel} )}
</span> {paymentDateLabel && (
<span className="text-success">{paymentDateLabel}</span>
)} )}
</div> </div>
<Badge variant="outline">{isPaid ? "Pago" : "Pendente"}</Badge>
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div> </div>
<span className="font-medium"> <MoneyValues
<MoneyValues amount={event.transaction.amount} /> className="font-medium whitespace-nowrap"
</span> amount={event.transaction.amount}
/>
</div> </div>
</EventCard> </EventCard>
); );
}; };
const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => ( const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => {
<EventCard type="card"> const paymentDateLabel = event.card.isPaid
<div className="flex items-start justify-between gap-3"> ? formatFinancialDateLabel(event.card.paymentDate, "Pago em", DATE_FORMAT)
<div className="flex flex-col gap-1"> : null;
<div className="flex gap-1 items-center">
<span className="text-sm font-medium leading-tight">
Vencimento Fatura - {event.card.name}
</span>
</div>
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge> return (
<EventCard type="card">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium leading-tight">
Vencimento Fatura {event.card.name}
</span>
{paymentDateLabel && (
<span className="text-xs text-success">{paymentDateLabel}</span>
)}
<Badge variant="outline">
{event.card.isPaid ? "Pago" : (event.card.status ?? "Fatura")}
</Badge>
</div>
{event.card.totalDue !== null ? (
<MoneyValues
className="font-medium whitespace-nowrap"
amount={event.card.totalDue}
/>
) : null}
</div> </div>
{event.card.totalDue !== null ? ( </EventCard>
<span className="font-medium"> );
<MoneyValues amount={event.card.totalDue} /> };
</span>
) : null} const renderInstallment = (
</div> event: Extract<CalendarEvent, { type: "installment" }>,
</EventCard> ) => {
); const isReceita = event.transaction.transactionType === "Receita";
return (
<EventCard type="installment">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium leading-tight">
{event.transaction.name}
</span>
<Badge variant="outline">{event.installmentCount}x parcelas</Badge>
</div>
<div className="flex flex-col items-end gap-0.5">
<MoneyValues
showPositiveSign
className={cn(
"text-base whitespace-nowrap font-medium",
isReceita ? "text-success" : "text-foreground",
)}
amount={event.installmentValue}
/>
<span className="text-xs text-muted-foreground">por parcela</span>
</div>
</div>
</EventCard>
);
};
const SECTION_LABELS: Record<CalendarEvent["type"], string> = {
transaction: "Lançamentos",
installment: "Parcelas",
boleto: "Boletos",
card: "Faturas",
};
const renderEvent = (event: CalendarEvent) => { const renderEvent = (event: CalendarEvent) => {
switch (event.type) { switch (event.type) {
case "transaction": case "transaction":
return renderLancamento(event); return renderLancamento(event);
case "installment":
return renderInstallment(event);
case "boleto": case "boleto":
return renderBoleto(event); return renderBoleto(event);
case "card": case "card":
@@ -169,28 +213,51 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
onCreate(day.date); onCreate(day.date);
}; };
const description = day?.events.length const hasEvents = Boolean(day?.events.length);
? "Confira os lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora."; const grouped = day
? {
transaction: day.events.filter((e) => e.type === "transaction"),
installment: day.events.filter((e) => e.type === "installment"),
boleto: day.events.filter((e) => e.type === "boleto"),
card: day.events.filter((e) => e.type === "card"),
}
: null;
return ( return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}> <Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{formattedDate}</DialogTitle> <DialogTitle>{formattedDate}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>
{hasEvents
? "Lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia."}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2"> <div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
{day?.events.length ? ( {hasEvents && grouped ? (
day.events.map((event) => ( (["transaction", "installment", "boleto", "card"] as const)
<div key={event.id}>{renderEvent(event)}</div> .filter((type) => grouped[type].length > 0)
)) .map((type) => (
<div key={type} className="space-y-1.5">
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{SECTION_LABELS[type]}
</p>
<div className="space-y-1.5">
{grouped[type].map((event) => (
<div key={event.id}>{renderEvent(event)}</div>
))}
</div>
</div>
))
) : ( ) : (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border/60 bg-muted/30 p-8 text-center">
Nenhum lançamento ou vencimento registrado. Clique em{" "} <RiCalendarEventLine className="size-8 text-muted-foreground/50" />
<span className="font-medium text-primary">Novo lançamento</span>{" "} <p className="text-sm text-muted-foreground">
para começar. Nenhum lançamento registrado para este dia.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -17,6 +17,7 @@ import { parsePeriod } from "@/shared/utils/period";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência"; const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
const PAYMENT_PREFIX = "Pagamento fatura - ";
const clampDayInMonth = (year: number, monthIndex: number, day: number) => { const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
@@ -88,19 +89,28 @@ export const fetchCalendarData = async ({
const transactionData = mapTransactionsData(transactionRows); const transactionData = mapTransactionsData(transactionRows);
const events: CalendarEvent[] = []; const events: CalendarEvent[] = [];
// Totais por cartão para exibir no vencimento
const cardTotals = new Map<string, number>(); const cardTotals = new Map<string, number>();
for (const item of transactionData) { for (const item of transactionData) {
if (!item.cardId || item.period !== period) { if (!item.cardId || item.period !== period) continue;
continue;
}
const amount = Math.abs(item.amount ?? 0); const amount = Math.abs(item.amount ?? 0);
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount); cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
} }
// Pagamentos de fatura por nome do cartão → data de pagamento
const paymentByCardName = new Map<string, string | null>();
for (const item of transactionData) { for (const item of transactionData) {
if (!item.name.startsWith(PAYMENT_PREFIX)) continue;
const cardName = item.name.slice(PAYMENT_PREFIX.length);
paymentByCardName.set(cardName, item.purchaseDate?.slice(0, 10) ?? null);
}
for (const item of transactionData) {
// Pagamentos de fatura são consumidos pelos eventos de cartão
if (item.name.startsWith(PAYMENT_PREFIX)) continue;
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO; const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
// Para boletos, exibir apenas na data de vencimento
if (isBoleto) { if (isBoleto) {
if ( if (
item.dueDate && item.dueDate &&
@@ -114,7 +124,6 @@ export const fetchCalendarData = async ({
}); });
} }
} else { } else {
// Para outros tipos de lançamento, exibir na data de compra
const purchaseDateKey = item.purchaseDate.slice(0, 10); const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) { if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
events.push({ events.push({
@@ -127,23 +136,60 @@ export const fetchCalendarData = async ({
} }
} }
// Exibir vencimentos apenas de cartões com lançamentos do período // Agrupar parcelas da mesma série em um único evento
const installmentGroups = new Map<
string,
Array<Extract<CalendarEvent, { type: "transaction" }>>
>();
for (const event of events) {
if (event.type !== "transaction") continue;
const { seriesId, installmentCount } = event.transaction;
if (!seriesId || !installmentCount || installmentCount <= 1) continue;
const group = installmentGroups.get(seriesId) ?? [];
group.push(event as Extract<CalendarEvent, { type: "transaction" }>);
installmentGroups.set(seriesId, group);
}
const groupedSeriesIds = new Set<string>();
const installmentEvents: CalendarEvent[] = [];
for (const [seriesId, group] of installmentGroups) {
if (group.length < 2) continue;
groupedSeriesIds.add(seriesId);
const rep = group[0];
installmentEvents.push({
id: `${seriesId}:installment`,
type: "installment",
date: rep.date,
transaction: rep.transaction,
installmentCount: rep.transaction.installmentCount ?? group.length,
installmentValue: rep.transaction.amount ?? 0,
});
}
const baseEvents = events.filter((e) => {
if (e.type !== "transaction") return true;
const { seriesId } = e.transaction;
return !seriesId || !groupedSeriesIds.has(seriesId);
});
const allEvents = [...baseEvents, ...installmentEvents];
// Vencimentos de cartões com lançamentos no período
for (const card of cardRows) { for (const card of cardRows) {
if (!cardTotals.has(card.id)) { if (!cardTotals.has(card.id)) continue;
continue;
}
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10); const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
if (Number.isNaN(dueDayNumber)) { if (Number.isNaN(dueDayNumber)) continue;
continue;
}
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber); const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
const dueDateKey = formatDateKey( const dueDateKey = formatDateKey(
new Date(Date.UTC(year, monthIndex, normalizedDay)), new Date(Date.UTC(year, monthIndex, normalizedDay)),
); );
events.push({ const isPaid = paymentByCardName.has(card.name);
const paymentDate = paymentByCardName.get(card.name) ?? null;
allEvents.push({
id: `${card.id}:cartao`, id: `${card.id}:cartao`,
type: "card", type: "card",
date: dueDateKey, date: dueDateKey,
@@ -156,17 +202,20 @@ export const fetchCalendarData = async ({
status: card.status, status: card.status,
logo: card.logo ?? null, logo: card.logo ?? null,
totalDue: cardTotals.get(card.id) ?? null, totalDue: cardTotals.get(card.id) ?? null,
isPaid,
paymentDate,
}, },
}); });
} }
const typePriority: Record<CalendarEvent["type"], number> = { const typePriority: Record<CalendarEvent["type"], number> = {
transaction: 0, transaction: 0,
installment: 0,
boleto: 1, boleto: 1,
card: 2, card: 2,
}; };
events.sort((a, b) => { allEvents.sort((a, b) => {
if (a.date === b.date) { if (a.date === b.date) {
return typePriority[a.type] - typePriority[b.type]; return typePriority[a.type] - typePriority[b.type];
} }
@@ -182,7 +231,7 @@ export const fetchCalendarData = async ({
const estabelecimentos = await fetchRecentEstablishments(userId); const estabelecimentos = await fetchRecentEstablishments(userId);
return { return {
events, events: allEvents,
formOptions: { formOptions: {
payerOptions: optionSets.payerOptions, payerOptions: optionSets.payerOptions,
splitPayerOptions: optionSets.splitPayerOptions, splitPayerOptions: optionSets.splitPayerOptions,

View File

@@ -13,10 +13,10 @@ import { db } from "@/shared/lib/db";
import { import {
dayOfMonthSchema, dayOfMonthSchema,
noteSchema, noteSchema,
optionalDecimalSchema, requiredDecimalSchema,
uuidSchema, uuidSchema,
} from "@/shared/lib/schemas/common"; } from "@/shared/lib/schemas/common";
import { formatDecimalForDb } from "@/shared/utils/currency"; import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { normalizeFilePath } from "@/shared/utils/string"; import { normalizeFilePath } from "@/shared/utils/string";
const cardBaseSchema = z.object({ const cardBaseSchema = z.object({
@@ -35,7 +35,7 @@ const cardBaseSchema = z.object({
closingDay: dayOfMonthSchema, closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema, dueDay: dayOfMonthSchema,
note: noteSchema, note: noteSchema,
limit: optionalDecimalSchema, limit: requiredDecimalSchema("limite"),
logo: z logo: z
.string({ message: "Selecione um logo." }) .string({ message: "Selecione um logo." })
.trim() .trim()
@@ -87,7 +87,7 @@ export async function createCardAction(
closingDay: data.closingDay, closingDay: data.closingDay,
dueDay: data.dueDay, dueDay: data.dueDay,
note: data.note ?? null, note: data.note ?? null,
limit: formatDecimalForDb(data.limit), limit: formatDecimalForDbRequired(data.limit),
logo: logoFile, logo: logoFile,
accountId: data.accountId, accountId: data.accountId,
userId: user.id, userId: user.id,
@@ -121,7 +121,7 @@ export async function updateCardAction(
closingDay: data.closingDay, closingDay: data.closingDay,
dueDay: data.dueDay, dueDay: data.dueDay,
note: data.note ?? null, note: data.note ?? null,
limit: formatDecimalForDb(data.limit), limit: formatDecimalForDbRequired(data.limit),
logo: logoFile, logo: logoFile,
accountId: data.accountId, accountId: data.accountId,
}) })

View File

@@ -154,13 +154,21 @@ export function CardDialog({
} }
const rawLimit = normalizeDecimalInput(formState.limit); const rawLimit = normalizeDecimalInput(formState.limit);
const limitValue = rawLimit ? Number(rawLimit) : 0;
if (!Number.isFinite(limitValue) || limitValue <= 0) {
const message = "Informe um limite maior que zero.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload: CardCreatePayload = { const payload: CardCreatePayload = {
name: formState.name.trim(), name: formState.name.trim(),
brand: formState.brand, brand: formState.brand,
status: formState.status, status: formState.status,
closingDay: formState.closingDay, closingDay: formState.closingDay,
dueDay: formState.dueDay, dueDay: formState.dueDay,
limit: rawLimit ? Number(rawLimit) : null, limit: limitValue,
note: formState.note.trim() || null, note: formState.note.trim() || null,
logo: formState.logo, logo: formState.logo,
accountId: formState.accountId, accountId: formState.accountId,
@@ -194,12 +202,12 @@ export function CardDialog({
}); });
}; };
const title = mode === "create" ? "Novo cartão" : "Editar cartão"; const title = mode === "create" ? "Novo cartão" : "Atualizar cartão";
const description = const description =
mode === "create" mode === "create"
? "Inclua um novo cartão de crédito para acompanhar seus gastos." ? "Inclua um novo cartão de crédito para acompanhar seus gastos."
: "Atualize as informações do cartão selecionado."; : "Atualize as informações do cartão selecionado.";
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão"; const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
const handleMainDialogOpenChange = (open: boolean) => { const handleMainDialogOpenChange = (open: boolean) => {
if (!open && logoDialogOpen) { if (!open && logoDialogOpen) {

View File

@@ -112,12 +112,13 @@ export function CardFormFields({
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="card-limit">Limite (R$)</Label> <Label htmlFor="card-limit">Limite</Label>
<CurrencyInput <CurrencyInput
id="card-limit" id="card-limit"
value={values.limit} value={values.limit}
onValueChange={(value) => onChange("limit", value)} onValueChange={(value) => onChange("limit", value)}
placeholder="R$ 0,00" placeholder="R$ 0,00"
required
/> />
</div> </div>

View File

@@ -30,9 +30,9 @@ interface CardItemProps {
status: string; status: string;
closingDay: string; closingDay: string;
dueDay: string; dueDay: string;
limit: number | null; limit: number;
limitInUse?: number | null; limitInUse?: number;
limitAvailable?: number | null; limitAvailable?: number;
accountName: string; accountName: string;
logo?: string | null; logo?: string | null;
note?: string | null; note?: string | null;
@@ -61,62 +61,22 @@ export function CardItem({
}: CardItemProps) { }: CardItemProps) {
void _accountName; void _accountName;
const limitTotal = limit ?? null;
const used = const used =
limitInUse ?? limitInUse ??
(limitTotal !== null && limitAvailable != null (limitAvailable !== undefined ? Math.max(limit - limitAvailable, 0) : 0);
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
const available = const available = limitAvailable ?? Math.max(limit - used, 0);
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const usagePercent = const usagePercent =
limitTotal && limitTotal > 0 && used !== null limit > 0 ? Math.min(Math.max((used / limit) * 100, 0), 100) : 0;
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand); const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo"; const isInactive = status?.toLowerCase() === "inativo";
const metrics =
limitTotal === null || used === null || available === null
? null
: [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
];
return ( return (
<Card className="flex flex-col p-6 w-full"> <Card className="flex flex-col p-6 w-full">
<CardHeader className="space-y-2 px-0 pb-0"> <CardHeader className="space-y-2 p-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
{logoPath ? ( {logoPath ? (
@@ -135,8 +95,8 @@ export function CardItem({
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base"> <h3 className="truncate font-semibold text-foreground">
{name} {name}
</h3> </h3>
{note ? ( {note ? (
@@ -166,14 +126,14 @@ export function CardItem({
</div> </div>
{brandAsset ? ( {brandAsset ? (
<div className="flex items-center justify-center py-1"> <div className="flex items-center justify-center py-2">
<Image <Image
src={brandAsset} src={brandAsset}
alt={`Bandeira ${brand}`} alt={`Bandeira ${brand}`}
width={36} width={36}
height={36} height={36}
className={cn( className={cn(
"h-5 w-auto rounded", "h-4 w-auto rounded",
isInactive && "grayscale opacity-40", isInactive && "grayscale opacity-40",
)} )}
/> />
@@ -185,79 +145,89 @@ export function CardItem({
)} )}
</div> </div>
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm"> <div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
<span> <span>
Fecha dia{" "} Fecha em{" "}
<span className="font-medium text-foreground"> <span className="font-semibold text-foreground">
{formatDay(closingDay)} dia {formatDay(closingDay)}
</span> </span>
</span> </span>
<span> <span>
Vence dia{" "} Vence em{" "}
<span className="font-medium text-foreground"> <span className="font-semibold text-foreground">
{formatDay(dueDay)} dia {formatDay(dueDay)}
</span> </span>
</span> </span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 px-0"> <CardContent className="flex flex-1 flex-col gap-4 px-0">
{metrics ? ( <div className="flex flex-col gap-0.5">
<> <span className="text-xs text-muted-foreground">
<div className="grid grid-cols-3 gap-4"> Limite disponível
<div className="flex flex-col items-start gap-1"> </span>
<p className="text-sm font-semibold text-foreground"> <MoneyValues
<MoneyValues amount={metrics[0].value} /> amount={available}
</p> className="text-xl font-semibold text-success"
<span className="text-xs text-muted-foreground"> />
{metrics[0].label} </div>
</span>
</div>
<div className="flex flex-col items-center gap-1"> <div className="grid grid-cols-2 gap-2">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground"> <div className="flex flex-col gap-0.5">
<span className="size-2 rounded-full bg-primary" /> <span className="text-xs text-muted-foreground">Limite total</span>
<MoneyValues amount={metrics[1].value} /> <MoneyValues
</p> amount={limit}
<span className="text-xs text-muted-foreground"> className="text-sm font-semibold text-foreground"
{metrics[1].label} />
</span> </div>
</div> <div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite utilizado
</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-destructive"
/>
</div>
</div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col gap-2">
<p className="text-sm font-semibold text-foreground"> <Progress
<MoneyValues amount={metrics[2].value} /> value={usagePercent}
</p> className="h-2.5"
<span className="text-xs text-muted-foreground"> aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
{metrics[2].label} />
</span> <span className="text-xs text-muted-foreground">
</div> {usagePercent.toFixed(1)}% utilizado
</div> </span>
</div>
<Progress value={usagePercent} className="h-3" />
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
</CardContent> </CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm"> <CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
{actions.map(({ label, icon, onClick, className }) => ( <button
<button type="button"
key={label} onClick={onEdit}
type="button" className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
onClick={onClick} >
className={cn( <RiPencilLine className="size-4" aria-hidden />
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80", editar
className, </button>
)} <button
> type="button"
{icon} onClick={onInvoice}
{label} className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
</button> >
))} <RiFileList2Line className="size-4" aria-hidden />
ver fatura
</button>
<button
type="button"
onClick={onRemove}
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
>
<RiDeleteBin5Line className="size-4" aria-hidden />
remover
</button>
</CardFooter> </CardFooter>
</Card> </Card>
); );

View File

@@ -201,7 +201,7 @@ export function CardsPage({
onOpenChange={handleRemoveOpenChange} onOpenChange={handleRemoveOpenChange}
title={removeTitle} title={removeTitle}
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente." description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
confirmLabel="Remover cartão" confirmLabel="Remover"
pendingLabel="Removendo..." pendingLabel="Removendo..."
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}

View File

@@ -7,11 +7,11 @@ export type Card = {
dueDay: string; dueDay: string;
note: string | null; note: string | null;
logo: string | null; logo: string | null;
limit: number | null; limit: number;
accountId: string; accountId: string;
accountName: string; accountName: string;
limitInUse: number; limitInUse: number;
limitAvailable: number | null; limitAvailable: number;
}; };
export type CardFormValues = { export type CardFormValues = {

View File

@@ -3,7 +3,7 @@ import { cards, financialAccounts, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { loadLogoOptions } from "@/shared/lib/logo/options"; import { loadLogoOptions } from "@/shared/lib/logo/options";
export type CardData = { type CardData = {
id: string; id: string;
name: string; name: string;
brand: string; brand: string;
@@ -12,9 +12,9 @@ export type CardData = {
dueDay: string; dueDay: string;
note: string | null; note: string | null;
logo: string | null; logo: string | null;
limit: number | null; limit: number;
limitInUse: number; limitInUse: number;
limitAvailable: number | null; limitAvailable: number;
accountId: string; accountId: string;
accountName: string; accountName: string;
}; };
@@ -96,15 +96,12 @@ async function fetchCardsByStatus(
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note, note: card.note,
logo: card.logo, logo: card.logo,
limit: card.limit ? Number(card.limit) : null, limit: Number(card.limit),
limitInUse: (() => { limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0; return total < 0 ? Math.abs(total) : 0;
})(), })(),
limitAvailable: (() => { limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0; const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0); return Math.max(Number(card.limit) - inUse, 0);

View File

@@ -116,7 +116,7 @@ export async function updateCategoryAction(
revalidateForEntity("categories", user.id); revalidateForEntity("categories", user.id);
return { success: true, message: "Category atualizada com sucesso." }; return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }

View File

@@ -11,6 +11,7 @@ import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteCategoryAction } from "@/features/categories/actions"; import { deleteCategoryAction } from "@/features/categories/actions";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { import {
@@ -30,10 +31,10 @@ import {
import { import {
CATEGORY_TYPE_LABEL, CATEGORY_TYPE_LABEL,
CATEGORY_TYPES, CATEGORY_TYPES,
type CategoryType,
} from "@/shared/lib/categories/constants"; } from "@/shared/lib/categories/constants";
import { CategoryDialog } from "./category-dialog"; import { CategoryDialog } from "./category-dialog";
import { CategoryIconBadge } from "./category-icon-badge"; import type { Category } from "./types";
import type { Category, CategoryType } from "./types";
const CATEGORIAS_PROTEGIDAS = [ const CATEGORIAS_PROTEGIDAS = [
"Transferência interna", "Transferência interna",
@@ -249,7 +250,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
onOpenChange={handleRemoveOpenChange} onOpenChange={handleRemoveOpenChange}
title={removeTitle} title={removeTitle}
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados." description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
confirmLabel="Remover categoria" confirmLabel="Remover"
pendingLabel="Removendo..." pendingLabel="Removendo..."
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}

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