55 Commits

Author SHA1 Message Date
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
Felipe Coutinho
c29ffa9a12 docs: registrar v2.4.0 — integração Logo.dev; atualizar screenshots
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:27:03 +00:00
Felipe Coutinho
8875de843b chore(deps): separar radix-ui em pacotes individuais e atualizar dependências; bump 2.4.0
- Remove pacote `radix-ui` (bundle monolítico); importa direto `@radix-ui/react-navigation-menu` e `@radix-ui/react-slider`
- Bump: @ai-sdk/* , @aws-sdk/* , @tanstack/react-query, ai, resend, dotenv, knip, @biomejs/biome, @types/node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:56 +00:00
Felipe Coutinho
679ea752bb feat(logo): integração Logo.dev para logos automáticos de estabelecimentos
- Nova tabela `establishment_logos` no schema (userId + nameKey → domain)
- Utilitários: `buildLogoDevUrl`, `toNameKey`, `logoQueryKeys`, `LOGO_DEV_TOKEN`
- `EstablishmentLogo`: exibe logo via Logo.dev com fallback para iniciais; hover mostra ícone de edição
- `EstablishmentLogoPicker`: popover para buscar e fixar domínio Logo.dev por estabelecimento
- API routes: `GET /api/logo/mapping` e `GET /api/logo/search`
- Server actions/queries para persistência do mapeamento por usuário
- CSP: libera `https://img.logo.dev` em `img-src`
- `.env.example`: variáveis `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:48 +00:00
Felipe Coutinho
1161e97d9e docs: reestruturar README em dois perfis; bump 2.3.8
- README: perfil Usar (só Docker) e Desenvolver (hot-reload)
- README: seção Docker simplificada; scripts atualizados
- CHANGELOG: entrada 2.3.8 com mudanças de infraestrutura Docker

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:45:19 +00:00
Felipe Coutinho
55d7dedd9a chore(scripts): reduzir scripts docker de 10 para 5
docker:up, docker:db, docker:down, docker:logs, docker:update

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:45:15 +00:00
Felipe Coutinho
ad2752b7b0 chore(docker): simplificar compose e entrypoint
- compose: removidos profiles, build e dependência de arquivo externo;
  agora standalone com curl + docker compose up -d
- compose: variáveis opcionais movidas para .env via env_file
- entrypoint: extensão pgcrypto criada via Node.js antes das migrations
- entrypoint: loop de retry reescrito; removido hack @localhost→@db

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:45:12 +00:00
Felipe Coutinho
58db357cde docs: reescrever README com guia de instalação para leigos; atualizar changelog 2.3.7
- README: seção "Como rodar" reescrita com 4 modos explicados para leigos
- README: seção Docker atualizada (sem .env obrigatório, localhost funciona)
- package.json: corrigir env:setup apontando para setup-env.sh deletado → setup.mjs
- CHANGELOG 2.3.7: documentar fix do localhost→db e default DATABASE_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:49:40 +00:00
Felipe Coutinho
99a9ff5512 fix(docker): resolver DATABASE_URL localhost→db no container automaticamente
- docker-entrypoint.sh: substituir @localhost: por @db: via sed antes das
  migrations e do Next.js subirem — transparente para o usuário
- docker-compose.yml: adicionar valor padrão para DATABASE_URL para
  permitir subir sem .env configurado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:48:20 +00:00
Felipe Coutinho
5bcf4f69d3 chore(scripts): remover órfãos dev.ts e setup-env.sh; atualizar changelog 2.3.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:17:20 +00:00
Felipe Coutinho
95099c1a94 chore(docker): passar PUBLIC_DOMAIN e variáveis Umami para o container
Adiciona PUBLIC_DOMAIN, UMAMI_URL, UMAMI_WEBSITE_ID e UMAMI_DOMAINS
ao bloco de environment do serviço app no docker-compose.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:16:44 +00:00
Felipe Coutinho
94912f7edc fix(scripts): corrigir install-deps.sh — spinner, corepack e PATH
- spinner_stop: adicionar || true em kill/wait para evitar exit com set -e
- suprimir prompt interativo do corepack com COREPACK_ENABLE_DOWNLOAD_PROMPT=0
- exportar PATH do Homebrew antes do resumo para pnpm --version funcionar
- remover mensagem "próximo passo" do final do script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:16:40 +00:00
Felipe Coutinho
bf6adfa3f1 chore(analytics): mover configuração do Umami para variáveis de ambiente
- UMAMI_URL, UMAMI_WEBSITE_ID e UMAMI_DOMAINS carregados via process.env
- script só é injetado se as vars estiverem definidas
- CSP atualizada dinamicamente com base no UMAMI_URL
- documentado no .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:50:03 +00:00
Felipe Coutinho
e4b9dd4254 chore: versão 2.3.7 — corrigir versão e consolidar CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:49 +00:00
Felipe Coutinho
f1907c8697 fix(settings): ajuste de indentação e texto no formulário de exclusão de conta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:46 +00:00
Felipe Coutinho
805bcb863d fix(logo-picker): corrigir renderização de miniaturas no modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:43 +00:00
Felipe Coutinho
11b4f8940f feat(landing): aba de insights de IA e screenshots atualizados em webp lossless
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:40 +00:00
Felipe Coutinho
fba9686fdb feat(dashboard): tendências top 10 e padronização de espaçamento do inbox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:38 +00:00
Felipe Coutinho
9b8ac9f71f feat(payers): upload de avatar via arquivo com redimensionamento client-side
- círculo de upload no final da grade de avatares abre seletor de arquivo
- imagem redimensionada para 200×200px via Canvas e salva como base64
- suporte a data URLs em next/image com prop unoptimized
- object-cover adicionado ao componente base Avatar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:28 +00:00
Felipe Coutinho
fa41c78a39 feat(navbar): copiar user ID ao lado do nome no menu do usuário
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:16:18 +00:00
Felipe Coutinho
5f7bfb98da feat(settings): aba de diagnóstico e cópia de user ID no menu do usuário
- Nova aba "Diagnóstico" em Settings com:
  - Identidade: user ID (copiável), nome, e-mail
  - Sessão: criada em / expira em
  - Aplicação: versão, NODE_ENV, build SHA (se definido)
  - Configuração do servidor: S3, e-mail e domínio público — apenas booleans, sem expor credenciais
  - Saúde: status e latência do banco de dados
  - Uso: contagem de lançamentos, anexos, anotações e itens no inbox
- Botão de cópia do user ID no dropdown do avatar (ao lado do e-mail)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:06:40 +00:00
Felipe Coutinho
9ecafdb15f docs: atualizar CLAUDE.md e README; adicionar script de instalação Ubuntu
- CLAUDE.md: rota attachments/ adicionada ao mapa de diretórios (app e features);
  seção Response Style substituída por Security Rules
- README.md: instruções para servidor Ubuntu 24.04, preview do Companion,
  seção de Backup; menção ao Companion atualizada
- scripts/install-deps.sh: prepara VPS Ubuntu limpa instalando Docker,
  Node.js 22 e pnpm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:39 +00:00
Felipe Coutinho
e8cc673e52 style(ui): padronizar tipografia — font-medium para font-semibold
Padronização de peso tipográfico em títulos, rótulos de seção,
nomes de entidades e valores monetários em toda a interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:34 +00:00
Felipe Coutinho
3bd8117b65 fix(i18n): corrigir textos "Payer" para "Pagador" em mensagens de erro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:21 +00:00
Felipe Coutinho
a7268d8f05 feat(inbox): redesenho do card de pré-lançamento
Logo maior (40px), nome do app em font-semibold, data em linha
separada e valor monetário em destaque — melhor hierarquia visual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:17 +00:00
Felipe Coutinho
1f9098879e feat(parcelas): redesenho do card de grupo com dialog de detalhes
Card de grupo de parcelas ganhou um dialog ao clicar em "Ver detalhes",
separando parcelas pagas e pendentes, com seleção parcial e logo do
estabelecimento. Substituída lógica de expand inline pelo dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:14 +00:00
Felipe Coutinho
7a3bff52ac feat(dashboard): novos widgets de anexos, inbox e tendências de categoria
- Widget Anexos: resumo de arquivos do período (total, imagens, PDFs, recentes)
- Widget Inbox: snapshot de pré-lançamentos pendentes do Companion
- Widget Tendências de Categoria: redireciona para relatório de tendências
- fetch-dashboard-data: busca attachmentsSnapshot e inboxSnapshot em paralelo
- widgets-config: tipo DashboardWidgetQuickActionOptions centralizado; props
  adminPayerSlug e quickActionOptions adicionadas ao contrato do widget
- dashboard-grid-editable: usa o novo tipo unificado de quickActionOptions
- proxy.ts: frame-src adicionado à CSP para preview de PDFs via S3
- rota /attachments criada com layout próprio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:09 +00:00
Felipe Coutinho
dfb4126b12 feat(lancamentos): filtros de status e anexo; feedback visual de fatura paga
- Novos filtros no drawer: somente pagos, somente não pagos, com anexo
- Filtros de tipo/condição/pagamento agora usam slugs na URL (sem acentos)
- Coluna de liquidação: lançamentos de cartão com fatura paga exibem ícone
  verde com tooltip — diferenciando do estado pendente
- EstabelecimentoInput: popover respeita largura do input ao abrir
- slugify extraído para shared/utils/string.ts
- INVOICE_PAYMENT_CATEGORY_NAME adicionado em categories/constants.ts
- SETTLED_FILTER_VALUES adicionado em transactions/constants.ts
- establishment-logo.tsx removido (não utilizado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:59 +00:00
Felipe Coutinho
ffead579fa feat(fontes): substituir fonte local America por Inter (Google Fonts)
Next.js self-hosta a Inter em build time — elimina os arquivos .woff2
do repositório e a dependência de localFont.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:50 +00:00
Felipe Coutinho
aa85cf8b29 fix(docker,s3): corrigir CRLF no entrypoint e região S3 vazia — v2.3.7
- Adicionado .gitattributes com eol=lf para scripts shell e Dockerfile
- Dockerfile: sed -i 's/\r$//' no entrypoint para eliminar CRLF em ambientes Windows/WSL2
- s3-client.ts: substituído ?? por || para tratar string vazia em S3_REGION e demais vars
- CHANGELOG, package.json e lockfile atualizados para v2.3.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:44 +00:00
Felipe Coutinho
9a7ae0fa3d fix(docker): adicionar NODE_PATH no entrypoint para resolução do drizzle-orm
Corrige erro "Cannot find module 'drizzle-orm'" ao rodar migrations no
container — o drizzle-kit em /app/migrate/ não encontrava o módulo sem
NODE_PATH apontando para o node_modules isolado.

Closes #34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:35:52 +00:00
Felipe Coutinho
98fe6a0f4f Update version badge from 2.3.4 to 2.3.5 2026-04-07 10:53:10 -03:00
Felipe Coutinho
d10eae13e5 Revise versioning and commit message guidelines
Updated versioning instructions to include README.md updates and clarified commit message guidelines.
2026-04-07 10:52:39 -03:00
Felipe Coutinho
43697b4fd2 fix(csp): mover CSP para proxy.ts para leitura em runtime
Content-Security-Policy estava em next.config.ts (build time),
então S3_ENDPOINT nunca era incluído no connect-src ao buildar
via Docker no CI. Movido para proxy.ts que avalia em runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:49:23 +00:00
344 changed files with 9397 additions and 7251 deletions

View File

@@ -3,10 +3,10 @@
# ============================================
# === Database ===
# PostgreSQL local (Docker): use host "db"
# PostgreSQL local (sem Docker): use host "localhost"
# Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
# Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
# PostgreSQL remoto: use URL completa do provider
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
POSTGRES_USER=openmonetis
@@ -44,8 +44,19 @@ GOOGLE_CLIENT_SECRET=
# Se não definido, todas as rotas ficam acessíveis.
# PUBLIC_DOMAIN=openmonetis.com
# === Analytics (Opcional) ===
# Umami: https://umami.is — self-hosted ou cloud
UMAMI_URL=
UMAMI_WEBSITE_ID=
UMAMI_DOMAINS=
# === AI Providers (Opcional) ===
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY=
OPENROUTER_API_KEY=
# === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Força LF para arquivos que precisam de line endings Unix no container
*.sh text eol=lf
docker-entrypoint.sh text eol=lf
Dockerfile text eol=lf

View File

@@ -7,6 +7,137 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased]
## [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/60`)
- 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
### Adicionado
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
- Estabelecimentos: picker de logo por estabelecimento — clique no avatar para buscar e fixar um domínio Logo.dev específico (salvo por usuário no banco)
- API: rotas `/api/logo/search` e `/api/logo/mapping` — proxy seguro para Logo.dev Brand Search API (secret key server-side) e consulta de mapeamentos salvos
- Schema: tabela `establishment_logos` com PK composta `(user_id, name_key)` para persistir preferências de logo por usuário
### Corrigido
- Dev: `.env.example` usava host `db` no `DATABASE_URL`, causando erro `EAI_AGAIN` ao rodar `pnpm dev` localmente — corrigido para `localhost`
### Documentação
- README: tabela comparativa entre Perfil 1 (Usar) e Perfil 2 (Desenvolver) com diferenças de setup, `DATABASE_URL` e instruções de atualização
- README: seção "Variáveis de Ambiente" esclarecida — distingue contexto Docker (Perfil 1) de desenvolvimento local (Perfil 2)
- Logo.dev: crie uma conta em logo.dev para obter as chaves `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` — plano gratuito inclui 500.000 requisições/mês
## [2.3.8] - 2026-04-12
### Alterado
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
- Docker: `docker-entrypoint.sh` simplificado — extensão `pgcrypto` criada via Node.js antes das migrations; loop de retry reescrito; removido hack `@localhost → @db`
- Docker: scripts reduzidos de 10 para 5 — `docker:up`, `docker:db`, `docker:down`, `docker:logs`, `docker:update`
- Docs: README reestruturado em dois perfis claros — **Usar** (só Docker) e **Desenvolver** (hot-reload)
## [2.3.7] - 2026-04-11
### Adicionado
- Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
- Docker: variáveis `PUBLIC_DOMAIN`, `UMAMI_URL`, `UMAMI_WEBSITE_ID` e `UMAMI_DOMAINS` passadas ao container da aplicação no `docker-compose.yml`
### Alterado
- Fonte: substituída fonte local `America` por `Inter` (Google Fonts, self-hosted pelo Next.js) — elimina arquivos `.woff2` do repositório
- Tipografia: peso tipográfico padronizado de `font-medium` para `font-semibold` em títulos, rótulos e valores monetários em toda a interface
- Parcelas: redesenho do card de grupo de parcelas — expandindo para dialog de detalhes com parcelas pagas/pendentes separadas
- Inbox: redesenho do card de pré-lançamento — logo maior, hierarquia tipográfica melhorada
- Lançamentos: filtros de tipo, condição e forma de pagamento agora usam slugs em URL (ex: `receita` em vez do valor literal com acentos)
- Estabelecimento: popover de autocomplete agora respeita a largura do input ao abrir
- CSP: adicionado `frame-src` para permitir preview de anexos PDF via S3
### Corrigido
- Docker: corrigido crash loop no container com mensagem `exec /app/docker-entrypoint.sh: no such file or directory` causado por CRLF no `docker-entrypoint.sh` em ambientes Windows/WSL2 — adicionado `sed -i 's/\r$//'` no Dockerfile e `.gitattributes` com `eol=lf` para scripts shell
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
- Scripts: `install-deps.sh` — spinner travava o script por `wait` retornar código não-zero com `set -e` ativo; corrigido com `|| true`
- Scripts: `install-deps.sh` — prompt interativo do corepack suprimido com `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
- Scripts: `install-deps.sh` — PATH do Homebrew não estava configurado na seção de resumo
### Removido
- Scripts: removidos arquivos órfãos `scripts/dev.ts` e `scripts/setup-env.sh` (substituídos pelo `setup.mjs`)
- Docker: `docker-compose.yml` agora funciona sem arquivo `.env``DATABASE_URL` tem valor padrão com credenciais de desenvolvimento
- Docker: `docker-entrypoint.sh` converte automaticamente `@localhost:` para `@db:` na `DATABASE_URL` ao iniciar o container, eliminando a necessidade de usar hosts diferentes no `.env` para desenvolvimento local e Docker
## [2.3.6] - 2026-04-09
### Corrigido
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
## [2.3.5] - 2026-04-07
### Corrigido
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
## [2.3.4] - 2026-04-05
### Corrigido

View File

@@ -16,9 +16,10 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
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.
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.
---
@@ -84,6 +85,7 @@ src/
│ │ ├── insights/
│ │ ├── calendar/
│ │ ├── inbox/
│ │ ├── attachments/
│ │ ├── changelog/
│ │ ├── reports/
│ │ │ ├── category-trends/
@@ -110,6 +112,7 @@ src/
│ ├── insights/
│ ├── calendar/
│ ├── inbox/
│ ├── attachments/
│ ├── reports/
│ └── settings/
├── shared/
@@ -214,7 +217,9 @@ Layouts, `loading.tsx` e metadata continuam em `src/app/`.
| `contas` | `accounts` |
| `categorias` | `categories` |
| `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` |
| `calendario` | `calendar` |
| `ajustes` | `settings` |
@@ -306,18 +311,29 @@ export async function fetchData(userId: string, period: string) {
---
## Response Style
## Security Rules
Quando o time pedir avaliacao de plano ou feature:
Regras aplicadas automaticamente ao gerar codigo.
1. Responder em portugues simples.
2. Listar 3-5 problemas principais.
3. Fechar com decisao pratica:
- aprova agora
- nao aprova agora
- o que ajustar antes de comecar codigo
### Secrets
Nunca colocar API keys, credenciais de banco ou tokens em codigo frontend. Evitar variaveis prefixadas com `NEXT_PUBLIC_` para dados sensiveis — estas sao bundladas no cliente. Usar variaveis server-side apenas. `.env` deve estar no `.gitignore` antes do primeiro commit. `.env.example` deve ter apenas placeholders.
Exemplo:
### Autenticacao & Autorizacao
Toda rota protegida em `src/app/api/` requer `getUser()` ou `getOptionalUserSession()` antes de qualquer logica, retornando 401 para nao autenticados. Rotas com IDs de recursos devem verificar ownership: `eq(table.userId, userId)`. Rotas admin devem checar role e retornar 403 para nao-admins. Session cookies em Better Auth ja tem `httpOnly`, `secure` e `sameSite` configurados — nao alterar.
- "Nao aprovaria para comecar codigo imediatamente."
- "Primeiro ajustaria o doc com estes 5 pontos."
### Input & Output
Usar Drizzle ORM (parametrizado por padrao) — nunca concatenar input de usuario em SQL. Validar todo input com Zod antes de usar. Upload de arquivos: usar whitelist de MIME types (`ALLOWED_MIME_TYPES`), presigned URLs para S3, token de upload assinado com verificacao pos-upload. Nunca usar `dangerouslySetInnerHTML` com conteudo de usuario.
### Headers & CSP
CSP definida em `src/proxy.ts` via middleware — alterar la, nao em `next.config.ts`. Headers de seguranca (HSTS, X-Frame-Options, etc.) definidos em `next.config.ts`. Nao remover nem enfraquecer essas configuracoes.
### Rate Limiting
Login: 5 tentativas/min. Signup: 3 tentativas/min. API tokens: 100 req/min (inbox), 20 req/min (batch). Configurado em `src/shared/lib/auth/config.ts` e nas rotas de inbox. Nao remover.
### Tratamento de Erros
Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Usar mensagens genericas: `"Algo deu errado"`. Logar detalhes apenas no servidor com `console.error()`.
### Dependencias
Verificar pacotes novos sugeridos pela IA em npmjs.com antes de instalar. Red flags: menos de 1.000 downloads/semana, publicado nos ultimos 30 dias, nome muito parecido com pacote popular. Rodar `pnpm audit` periodicamente.
---

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 \
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
RUN pnpm build
@@ -87,7 +91,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
# Copiar entrypoint de migrations
COPY docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
RUN sed -i 's/\r$//' /app/docker-entrypoint.sh && \
chmod +x /app/docker-entrypoint.sh && \
chown nextjs:nodejs /app/docker-entrypoint.sh
# Definir variáveis de ambiente de produção
ENV NODE_ENV=production \

322
README.md
View File

@@ -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.
[![Version](https://img.shields.io/badge/version-2.3.4-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.4.2-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -23,15 +23,21 @@
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
</p>
<p align="center">
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
</p>
---
## 📖 Índice
- [Sobre o Projeto](#-sobre-o-projeto)
- [Instalação via Script](#-instalação-via-script)
- [Início Rápido (manual)](#-início-rápido)
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
- [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
- [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
- [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker)
- [Backup](#-backup)
- [Storage S3 Compatível](#-storage-s3-compatível)
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
- [Arquitetura](#-arquitetura)
@@ -53,7 +59,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
**1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor.
**2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente ou importar extratos nos formatos OFX e XLS/XLSX.
**2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente, usar o app companion para capturar notificações bancárias ou importar extratos nos formatos OFX e XLS/XLSX.
**3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo.
@@ -77,7 +83,11 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia como pré-lançamentos para revisão. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia automaticamente como pré-lançamentos para revisão — sem digitar nada. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
<p align="center">
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p>
⚙️ **Personalização** — Tema dark/light e modo privacidade.
@@ -93,82 +103,122 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
---
## ⚡ Instalação via Script
## 🚀 Como rodar o OpenMonetis
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
Escolha o perfil que corresponde ao seu objetivo:
**Pré-requisito:** Node.js 22+
```bash
# Mac / Linux / WSL
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs
# Windows (PowerShell)
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
```
O script irá:
- Verificar Node, pnpm, Git e Docker
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
- Gerar o `BETTER_AUTH_SECRET` automaticamente
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
- Clonar o repositório, instalar dependências e aplicar o schema
| | Perfil 1 — Usar | Perfil 2 — Desenvolver |
|---|---|---|
| **Objetivo** | Rodar o app pronto | Modificar o código |
| **Clonar repositório** | Não | Sim |
| **Node.js / pnpm** | Não | Sim (Node 22+) |
| **Docker** | Sim | Sim |
| **Como iniciar** | `docker compose up -d` | `pnpm docker:db` + `pnpm dev` |
| **App roda em** | Container Docker | Host local (hot-reload) |
| **Banco roda em** | Container Docker | Container Docker |
| **`DATABASE_URL` (host)** | `db` (automático pelo compose) | `localhost` |
| **Banco remoto (Supabase, Neon...)** | Sim (`docker compose up -d app`) | Sim (ajustar `DATABASE_URL`) |
| **Como atualizar** | `pnpm docker:update` | `git pull` + `pnpm install` + `pnpm db:push` |
| **Indicado para** | Self-hosting, VPS, servidor | Contribuidores, customizações |
---
## 🚀 Início Rápido (manual)
### Perfil 1 — Usar (self-hosting)
### Pré-requisitos
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
- Node.js 22+ e pnpm
- Docker e Docker Compose
```bash
# 1. Baixe o compose
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
### Passo a Passo
# 2. Suba tudo
docker compose up -d
```
1. **Clone e instale**
Acesse em: `http://localhost:3000`
```bash
git clone https://github.com/felipegcoutinho/openmonetis.git
cd openmonetis
pnpm install
```
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir:
2. **Configure o `.env`**
```bash
# .env mínimo recomendado para produção
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
BETTER_AUTH_URL=https://seu-dominio.com
```
```bash
cp .env.example .env
```
Veja todas as variáveis disponíveis em [Variáveis de Ambiente](#-variáveis-de-ambiente).
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`:
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
```env
# Banco local (Docker): use host "localhost"
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
```bash
docker compose up -d app
```
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
```
```bash
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
sudo sh install-deps.sh
```
3. **Suba o banco de dados** (pule se estiver usando banco remoto)
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
```bash
docker compose up db -d
pnpm db:extensions
```
#### Atualizando (Perfil 1)
4. **Execute as migrations e inicie**
```bash
pnpm docker:update
# ou equivalente:
docker compose pull && docker compose up -d
```
```bash
pnpm db:push
pnpm dev
```
O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
5. Acesse `http://localhost:3000`
---
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4.
### Perfil 2 — Desenvolver
Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
**Requisitos:** Docker + Node.js 22+ + pnpm
```bash
# 1. Clone o repositório
git clone https://github.com/felipegcoutinho/openmonetis.git
cd openmonetis
# 2. Instale as dependências
pnpm install
# 3. Configure o ambiente
cp .env.example .env
# O DATABASE_URL já vem com host "localhost" (correto para dev local).
# Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
# 4. Suba o banco
pnpm docker:db
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
pnpm db:extensions
# 6. Aplique o schema no banco (apenas no primeiro setup)
pnpm db:push
# 7. Inicie o app com hot-reload
pnpm dev
```
Acesse em: `http://localhost:3000`
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
#### Atualizando (Perfil 2)
```bash
git pull
pnpm install # instala dependências novas, se houver
pnpm db:push # aplica mudanças de schema, se houver
```
O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
---
@@ -190,47 +240,62 @@ pnpm lint:fix # Biome auto-fix
pnpm db:generate # Gerar migrations
pnpm db:migrate # Executar migrations
pnpm db:push # Push schema direto (dev)
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
pnpm db:studio # Drizzle Studio (UI visual)
```
### Utilitários
```bash
pnpm backup # Backup do banco (requer scripts/backup.sh configurado)
pnpm backup # Backup completo do banco (ver seção Backup)
```
### Docker
```bash
pnpm docker:up # Subir app + banco
pnpm docker:up:d # Subir em background
pnpm docker:up:db # Subir apenas o banco
pnpm docker:down # Parar containers
pnpm docker:down:volumes # Parar e remover volumes (⚠️ apaga dados!)
pnpm docker:logs # Logs em tempo real
pnpm docker:restart # Reiniciar
pnpm docker:rebuild # Rebuild completo
pnpm docker:up # Sobe app (Docker Hub) + banco em background
pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
pnpm docker:down # Para e remove os containers
pnpm docker:logs # Logs em tempo real (todos os containers)
pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
```
---
## 🐳 Docker
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root.
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
### Self-hosting (recomendado)
Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
```bash
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
As credenciais padrão do banco já estão configuradas. Para personalizar (senhas, opcionais), crie um `.env` na mesma pasta antes de subir — veja [Variáveis de Ambiente](#-variáveis-de-ambiente).
### Banco remoto (Supabase, Neon, Railway...)
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
```bash
docker compose up -d app
```
### Comandos úteis
```bash
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 ps # Status
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
```
### Customizando Portas
### Customizando portas
```env
APP_PORT=3001 # Padrão: 3000
@@ -239,6 +304,68 @@ DB_PORT=5433 # Padrão: 5432
---
## 💾 Backup
O backup é uma rotina de infraestrutura — não é uma tela no app. Ele opera diretamente sobre o banco PostgreSQL e é executado via linha de comando.
```bash
pnpm backup
```
### O que é salvo
Cada execução gera **3 arquivos** em `backup/`:
| 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.sql.gz` | Dump SQL completo compactado | 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 |
### Modos de conexão
Configure `DB_MODE` no topo de `scripts/backup.sh`:
| Modo | Quando usar | Fonte de dados |
|---|---|---|
| `remote` (padrão) | Banco em Supabase, Neon, Railway, etc. | `DATABASE_URL` do `.env` |
| `docker` | Banco no container local | Container `openmonetis_postgres` |
### Upload para Google Drive (opcional)
Se o [rclone](https://rclone.org/) estiver instalado e configurado com um remote chamado `gdrive`, os arquivos são enviados automaticamente para `gdrive:BACKUP OPENMONETIS`. Sem o rclone, o backup funciona normalmente e fica apenas local.
**Retenção:**
- Local: 7 dias
- Google Drive: 30 dias
### Automatizar com cron
Para rodar o backup automaticamente todo dia às 3h:
```bash
crontab -e
```
```cron
0 3 * * * cd /caminho/para/openmonetis && pnpm backup >> /var/log/openmonetis-backup.log 2>&1
```
### Restore
```bash
# A partir do .dump (recomendado — mais rápido)
pg_restore --clean --no-owner --no-privileges \
-d "postgresql://user:senha@host:5432/openmonetis_db" \
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
# A partir do .sql.gz (banco local via Docker)
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
docker compose exec -T db psql -U openmonetis -d openmonetis_db
```
---
## ☁️ Storage S3 Compatível
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
@@ -256,20 +383,60 @@ S3_BUCKET=
### Compatibilidade
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](./src/shared/lib/storage/s3-client.ts).
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
---
## 🏷️ 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
Copie `.env.example` para `.env` e configure:
**Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
**Perfil 1 (Docker):** não precisa definir `DATABASE_URL` — o compose já configura automaticamente com host `db`. Só defina se usar banco remoto (Supabase, Neon, etc.).
### Obrigatórias
```env
# Perfil 2 (dev): host "localhost" — o banco roda em container, o app no host
# Perfil 1 (Docker): não precisa definir — o compose usa "db" automaticamente
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
@@ -306,6 +473,11 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_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.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -1,138 +1,52 @@
# Docker Compose para Next.js + PostgreSQL
name: openmonetis
# MODOS DE USO:
# 1. Banco LOCAL (PostgreSQL em container):
# - Configure DATABASE_URL com host "db" no .env
# - Execute: docker compose --profile local up
#
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
# - Configure DATABASE_URL com a URL do banco remoto no .env
# - Execute: docker compose up
#
# 3. Build local (desenvolvimento):
# - Execute: docker compose --profile local up --build
#
# 4. Para parar todos os serviços:
# - Execute: docker compose down
#
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
# - Execute: docker compose down -v
services:
# ============================================
# Serviço: PostgreSQL (Banco de dados local)
# Ativado apenas com: --profile local
# ============================================
db:
profiles: ["local"]
image: postgres:18-alpine
container_name: openmonetis_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
PGDATA: /var/lib/postgresql/data
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
exec docker-entrypoint.sh postgres
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}",
]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Para ativar logs de queries (debug), adicione ao command acima:
# exec docker-entrypoint.sh postgres -c log_statement=all
# ============================================
# Serviço: Aplicação Next.js
# ============================================
app:
build: .
image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
# Banco local: use host "db" | Banco remoto: URL completa do provider
DATABASE_URL: ${DATABASE_URL}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
# S3 (opcional)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_BUCKET: ${S3_BUCKET:-}
# Email (opcional)
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
# OAuth (opcional)
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
# AI providers (opcional)
ANTHROPIC_API_KEY: ${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:-}
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
depends_on:
db:
condition: service_healthy
required: false
healthcheck:
test:
[
"CMD",
"wget",
"--quiet",
"--tries=1",
"--spider",
"http://localhost:3000/api/health",
]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ============================================
# Volumes
# ============================================
volumes:
postgres_data:
driver: local

View File

@@ -1,15 +1,26 @@
#!/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..."
RETRIES=5
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
RETRIES=$((RETRIES - 1))
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
MIGRATED=0
for i in 1 2 3 4 5; do
if NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push; then
MIGRATED=1
break
fi
echo "Tentativa $i/5 falhou. Aguardando 5s..."
sleep 5
done
if [ "$RETRIES" -eq 0 ]; then
echo "Aviso: migrations nao foram aplicadas"
fi
[ "$MIGRATED" -eq 0 ] && echo "Aviso: migrations não foram aplicadas."
exec "$@"

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");

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,13 @@
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
}
]
}

View File

@@ -4,15 +4,17 @@ import type { NextConfig } from "next";
// Carregar variáveis de ambiente explicitamente
dotenv.config();
const isDev = process.env.NODE_ENV === "development";
const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"),
{ protocol: "https", hostname: "**" },
{ protocol: "http", hostname: "**" },
],
},
devIndicators: {
position: "bottom-right",
@@ -44,18 +46,6 @@ const nextConfig: NextConfig = {
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
"font-src 'self'",
`connect-src 'self' https://umami.felipecoutinho.com ${process.env.S3_ENDPOINT ? new URL(process.env.S3_ENDPOINT).origin : ""}`.trim(),
"frame-ancestors 'none'",
].join("; "),
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "2.3.4",
"version": "2.4.2",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
@@ -11,36 +11,37 @@
"lint": "biome check .",
"lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh",
"env:setup": "node setup.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up:db": "docker compose up -d db",
"docker:up:d": "docker compose up --build -d",
"docker:up": "docker compose up -d",
"//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
"docker:db": "docker compose up -d db",
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
"docker:down": "docker compose down",
"docker:down:volumes": "docker compose down -v",
"//docker:down": "Para e remove os containers",
"docker:logs": "docker compose logs -f",
"docker:logs:app": "docker compose logs -f app",
"docker:logs:db": "docker compose logs -f db",
"docker:restart": "docker compose restart",
"docker:rebuild": "docker compose up --build --force-recreate",
"backup": "bash scripts/backup.sh"
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
"docker:update": "docker compose pull && docker compose up -d",
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
"backup": "bash scripts/backup.sh",
"mockup": "tsx scripts/mock-data.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.65",
"@ai-sdk/google": "^3.0.55",
"@ai-sdk/openai": "^3.0.49",
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@better-auth/passkey": "^1.5.6",
"@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/openai": "^3.0.53",
"@aws-sdk/client-s3": "^3.1032.0",
"@aws-sdk/s3-request-presigner": "^3.1032.0",
"@better-auth/passkey": "^1.6.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@openrouter/ai-sdk-provider": "^2.8.0",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
@@ -49,11 +50,13 @@
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
@@ -61,11 +64,11 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query": "^5.99.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.143",
"better-auth": "1.5.6",
"@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.168",
"better-auth": "1.6.5",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -75,34 +78,38 @@
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "16.2.2",
"next": "16.2.4",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react": "19.2.5",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"react-dom": "19.2.5",
"recharts": "3.8.1",
"resend": "^6.10.0",
"resend": "^6.12.0",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"vaul": "1.1.2",
"zod": "4.3.6"
},
"pnpm": {
"overrides": {
"defu": "6.1.7"
}
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@biomejs/biome": "2.4.12",
"@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0",
"@types/node": "25.6.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.4.0",
"dotenv": "^17.4.2",
"drizzle-kit": "0.31.10",
"knip": "^6.3.0",
"knip": "^6.4.1",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "6.0.2"
"typescript": "6.0.3"
}
}

3152
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -1,18 +1,9 @@
import localFont from "next/font/local";
import { Inter } from "next/font/google";
export const america = localFont({
src: [
{
path: "./america-regular.woff2",
weight: "400",
style: "normal",
},
{
path: "./america-medium.woff2",
weight: "500",
style: "normal",
},
],
display: "fallback",
variable: "--font-america",
export const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
fallback: ["ui-sans-serif", "system-ui"],
preload: true,
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 176 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

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env tsx
import { execSync } from "node:child_process";
import { config } from "dotenv";
// Carregar variáveis de ambiente
config();
const port = process.env.PORT || "3000";
console.log(`Starting Next.js development server on port ${port}...`);
// Executar next dev com a porta especificada
execSync(`npx next dev --turbopack --port ${port}`, {
stdio: "inherit",
env: { ...process.env, PORT: port },
});

245
scripts/install-deps.sh Executable file
View File

@@ -0,0 +1,245 @@
#!/bin/sh
# install-deps.sh — Instala pré-requisitos do OpenMonetis
# Testado apenas em Ubuntu Server 24.04 LTS
# Uso: curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
# sudo sh install-deps.sh
set -e
LOG_FILE="/tmp/openmonetis-install.log"
> "$LOG_FILE"
# Suprimir prompt interativo do corepack ao chamar pnpm/node versioning
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# ── Cores ──────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
ok() { printf "${GREEN}${RESET} %s\n" "$1"; }
warn() { printf "${YELLOW}!${RESET} %s\n" "$1"; }
info() { printf "${CYAN}${RESET} %s\n" "$1"; }
fail() { printf "${RED}${RESET} %s\n" "$1"; exit 1; }
# ── Contador de etapas ─────────────────────────────────────────────────────────
_STEP=0
_TOTAL=5
section() {
_STEP=$((_STEP + 1))
printf "\n${BOLD}[%d/%d] %s${RESET}\n" "$_STEP" "$_TOTAL" "$1"
}
# ── Spinner ────────────────────────────────────────────────────────────────────
_spin_pid=""
spinner_start() {
_spin_label="$1"
( i=0
while true; do
case $((i % 4)) in
0) d=" " ;; 1) d=". " ;; 2) d=".. " ;; *) d="..." ;;
esac
printf "\r${CYAN}${RESET} %s%s" "$_spin_label" "$d"
i=$((i + 1))
sleep 0.4
done
) &
_spin_pid=$!
}
spinner_stop() {
if [ -n "$_spin_pid" ]; then
kill "$_spin_pid" 2>/dev/null || true
wait "$_spin_pid" 2>/dev/null || true
_spin_pid=""
printf "\r\033[2K"
fi
}
# ── Executores silenciosos com spinner ─────────────────────────────────────────
# run_quiet "label" cmd [args...] — roda comando com spinner, falha mostra log
run_quiet() {
_rq_label="$1"; shift
spinner_start "$_rq_label"
if ! "$@" >> "$LOG_FILE" 2>&1; then
spinner_stop
printf "${RED}✗ Falha em: %s${RESET}\n" "$_rq_label"
printf " Log completo: %s\n\n" "$LOG_FILE"
tail -20 "$LOG_FILE"
exit 1
fi
spinner_stop
}
# run_as_user "label" "comando_shell" — roda comando como $CURRENT_USER com spinner
run_as_user() {
_ru_label="$1"; shift
spinner_start "$_ru_label"
if ! su - "$CURRENT_USER" -c "$*" >> "$LOG_FILE" 2>&1; then
spinner_stop
printf "${RED}✗ Falha em: %s${RESET}\n" "$_ru_label"
printf " Log completo: %s\n\n" "$LOG_FILE"
tail -20 "$LOG_FILE"
exit 1
fi
spinner_stop
}
# ── Cleanup no Ctrl+C ──────────────────────────────────────────────────────────
cleanup() {
spinner_stop
printf "\n${YELLOW}Instalação interrompida.${RESET} Log em: %s\n" "$LOG_FILE"
exit 1
}
trap cleanup INT TERM
# ── Tempo total ────────────────────────────────────────────────────────────────
_START=$(date +%s)
elapsed() {
_secs=$(( $(date +%s) - _START ))
printf "%dm%ds" $((_secs / 60)) $((_secs % 60))
}
# ── Root check ─────────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
fail "Execute como root ou com sudo: sudo sh install-deps.sh"
fi
CURRENT_USER="${SUDO_USER:-$(whoami)}"
printf "\n${BOLD}OpenMonetis — Instalação de Dependências${RESET}\n"
printf "Usuário: ${CYAN}%s${RESET} | Log: %s\n" "$CURRENT_USER" "$LOG_FILE"
# ── [1/5] Dependências base ────────────────────────────────────────────────────
section "Dependências base"
run_quiet "Atualizando lista de pacotes" apt-get update -qq
run_quiet "Instalando git, curl, ca-certificates" apt-get install -y -qq ca-certificates curl git
ok "git $(git --version | cut -d' ' -f3) · curl · ca-certificates"
# ── [2/5] Docker ───────────────────────────────────────────────────────────────
section "Docker"
if command -v docker > /dev/null 2>&1; then
ok "Docker já instalado: $(docker --version | cut -d',' -f1)"
else
info "Adicionando repositório oficial do Docker..."
install -m 0755 -d /etc/apt/keyrings
run_quiet "Baixando chave GPG do Docker" \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
mkdir -p /etc/apt/sources.list.d
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu %s stable\n' \
"$(dpkg --print-architecture)" "$VERSION_CODENAME" \
> /etc/apt/sources.list.d/docker.list
run_quiet "Atualizando lista de pacotes" apt-get update -qq
run_quiet "Instalando Docker Engine (pode levar alguns minutos)" \
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker > /dev/null 2>&1 || true
systemctl start docker > /dev/null 2>&1 || true
ok "Docker $(docker --version | cut -d',' -f1 | cut -d' ' -f3) instalado"
fi
if docker compose version > /dev/null 2>&1; then
ok "Docker Compose $(docker compose version | cut -d' ' -f4)"
else
run_quiet "Instalando Docker Compose plugin" \
sh -c 'mkdir -p /usr/local/lib/docker/cli-plugins && curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose && chmod +x /usr/local/lib/docker/cli-plugins/docker-compose'
ok "Docker Compose $(docker compose version | cut -d' ' -f4) instalado"
fi
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
if ! groups "$CURRENT_USER" | grep -q docker; then
usermod -aG docker "$CURRENT_USER"
warn "Usuário '$CURRENT_USER' adicionado ao grupo docker — faça logout/login para aplicar"
else
ok "Usuário '$CURRENT_USER' já está no grupo docker"
fi
fi
# ── [3/5] Homebrew ─────────────────────────────────────────────────────────────
section "Homebrew"
if command -v brew > /dev/null 2>&1; then
ok "Homebrew já instalado: $(brew --version | head -1)"
else
warn "Esta etapa pode levar de 5 a 10 minutos."
run_quiet "Instalando dependências de compilação" \
apt-get install -y -qq build-essential procps file
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando Homebrew" \
'NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
BREW_PROFILE="/home/$CURRENT_USER/.bashrc"
BREW_EVAL='eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"'
grep -qxF "$BREW_EVAL" "$BREW_PROFILE" 2>/dev/null || echo "$BREW_EVAL" >> "$BREW_PROFILE"
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
else
fail "Homebrew não pode ser instalado como root. Use sudo com um usuário normal."
fi
ok "Homebrew instalado"
fi
# ── [4/5] Node.js 22 ───────────────────────────────────────────────────────────
section "Node.js 22"
NODE_MAJOR=0
if command -v node > /dev/null 2>&1; then
NODE_MAJOR=$(node -e "process.stdout.write(String(parseInt(process.versions.node)))")
fi
if [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null; then
ok "Node.js já instalado: $(node --version)"
else
warn "Node.js via Homebrew pode levar alguns minutos."
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando Node.js 22" \
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install node@22 && brew link node@22 --force --overwrite'
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
else
fail "Node.js via Homebrew não pode ser instalado como root."
fi
ok "Node.js $(node --version) instalado"
fi
# ── [5/5] pnpm ─────────────────────────────────────────────────────────────────
section "pnpm"
if command -v pnpm > /dev/null 2>&1; then
ok "pnpm já instalado: $(pnpm --version)"
else
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando pnpm via corepack" \
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
else
run_quiet "Instalando pnpm via corepack" \
sh -c 'corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
fi
ok "pnpm instalado"
fi
# ── Resumo ─────────────────────────────────────────────────────────────────────
# Garantir que node/pnpm do brew estejam no PATH para o resumo
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
ok "git: $(git --version | cut -d' ' -f3)"
ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
ok "docker compose: $(docker compose version | cut -d' ' -f4)"
ok "node: $(node --version)"
ok "pnpm: $(pnpm --version)"

View File

@@ -1,49 +0,0 @@
#!/bin/bash
# Script para configurar ambiente de forma segura
# Cria backup do .env atual antes de sobrescrever
set -e
echo "🔧 Configurando ambiente..."
# Se .env já existe, criar backup
if [ -f .env ]; then
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
echo "⚠️ Arquivo .env existente detectado!"
echo "📦 Criando backup em: $BACKUP_FILE"
cp .env "$BACKUP_FILE"
echo "✅ Backup criado com sucesso!"
echo ""
read -p "Deseja sobrescrever o .env atual com .env.example? (s/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
echo "❌ Operação cancelada. Seu .env não foi modificado."
exit 0
fi
fi
# Copiar .env.example para .env
if [ -f .env.example ]; then
cp .env.example .env
echo "✅ Arquivo .env criado a partir de .env.example"
else
echo "❌ Erro: .env.example não encontrado!"
exit 1
fi
# Gerar BETTER_AUTH_SECRET automaticamente
if command -v openssl &> /dev/null; then
SECRET=$(openssl rand -base64 32)
sed -i.bak "s|BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$SECRET|" .env && rm -f .env.bak
echo "✅ BETTER_AUTH_SECRET gerado automaticamente"
else
echo "⚠️ openssl não encontrado — configure BETTER_AUTH_SECRET manualmente:"
echo " openssl rand -base64 32"
fi
echo ""
echo "⚠️ IMPORTANTE: Edite o arquivo .env e configure:"
echo " - DATABASE_URL"
echo " - BETTER_AUTH_URL"
echo " - Demais variáveis opcionais (OAuth, e-mail, IA)"

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 { Logo } from "@/shared/components/logo";
export default function LoginPage() {
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">
<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>
);
return <LoginForm />;
}

View File

@@ -1,21 +1,5 @@
import { SignupForm } from "@/features/auth/components/signup-form";
import { Logo } from "@/shared/components/logo";
export default function SignupPage() {
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">
<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>
);
return <SignupForm />;
}

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBankLine />}
title="Contas"

View File

@@ -0,0 +1,26 @@
import { RiAttachmentLine } from "@remixicon/react";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Anexos",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6">
<PageDescription
icon={<RiAttachmentLine />}
title="Anexos"
subtitle="Gerencie os anexos das suas transações"
/>
<MonthNavigation />
{children}
</section>
);
}

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBarChart2Line />}
title="Orçamentos"

View File

@@ -20,22 +20,13 @@ const getSingleParam = (
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) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName: rawMonthName,
year,
} = parsePeriodParam(periodoParam);
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId,
@@ -49,7 +40,6 @@ export default async function Page({ searchParams }: PageProps) {
budgets={budgets}
categories={categoriesOptions}
selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
/>
</main>
);

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiCalendarEventLine />}
title="Calendário"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Cartões"

View File

@@ -1,6 +1,6 @@
import { connection } from "next/server";
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 { getCurrentPeriod } from "@/shared/utils/period";

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiHistoryLine />}
title="Changelog"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiAtLine />}
title="Pré-Lançamentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiSparklingLine />}
title="Insights"

View File

@@ -1,9 +1,10 @@
import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
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 { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server";
import { isLogoDevEnabled } from "@/shared/lib/logo/server";
export default async function DashboardLayout({
children,
@@ -13,33 +14,25 @@ export default async function DashboardLayout({
await connection();
const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id);
const logoDevEnabled = isLogoDevEnabled();
return (
<PrivacyProvider>
<AppNavbar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
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">
<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="@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}
<LogoDevProvider enabled={logoDevEnabled}>
<PrivacyProvider>
<AppNavbar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
preLancamentosCount={navbarData.preLancamentosCount}
notificationsSnapshot={navbarData.notificationsSnapshot}
/>
<div className="relative flex flex-1 flex-col pt-16">
<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>
</PrivacyProvider>
</PrivacyProvider>
</LogoDevProvider>
);
}

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiTodoLine />}
title="Anotações"

View File

@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
categoryFilter: null,
accountCardFilter: null,
searchFilter: null,
settledFilter: null,
attachmentFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({

View File

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

View File

@@ -1,7 +1,7 @@
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
*/
export default function PagadoresLoading() {
@@ -17,7 +17,7 @@ export default function PagadoresLoading() {
</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">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-md border p-6 space-y-4">

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Uso de Cartões"

View File

@@ -71,7 +71,7 @@ export default async function RelatorioCartoesPage({
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
<RiBankCard2Line className="size-7 text-muted-foreground" />
</div>
<p className="text-base font-medium">Nenhum cartão selecionado</p>
<p className="text-base font-semibold">Nenhum cartão selecionado</p>
<p className="text-sm text-muted-foreground mt-1">
Selecione um cartão para ver os detalhes de uso.
</p>

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiFileChartLine />}
title="Tendências"

View File

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

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiStore2Line />}
title="Top Estabelecimentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiSecurePaymentLine />}
title="Análise de Parcelas"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiSettings2Line />}
title="Ajustes"

View File

@@ -67,7 +67,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Preferências</h2>
<h2 className="text-xl font-semibold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades.
@@ -92,7 +92,9 @@ export default async function Page() {
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-medium">OpenMonetis Companion</h2>
<h2 className="text-xl font-semibold">
OpenMonetis Companion
</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" />
Android
@@ -114,7 +116,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
<h2 className="text-xl font-semibold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
@@ -130,7 +132,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
<h2 className="text-xl font-semibold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
@@ -146,7 +148,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
<h2 className="text-xl font-semibold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança.
@@ -162,7 +164,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
<h2 className="text-xl font-semibold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
@@ -182,9 +184,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1 text-destructive">
Ações perigosas
</h2>
<h2 className="text-xl font-semibold mb-1">Ações perigosas</h2>
<p className="text-sm text-muted-foreground">
Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível.

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiArrowLeftRightLine />}
title="Lançamentos"

View File

@@ -30,7 +30,6 @@ import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell"
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
export default async function Page() {
@@ -57,7 +56,7 @@ export default async function Page() {
<a
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors dark:text-white/75 dark:hover:text-white dark:hover:bg-white/10"
>
{label}
</a>
@@ -70,9 +69,9 @@ export default async function Page() {
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="outline"
variant="navbar"
size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
className="border border-black/20 dark:border-white/20"
>
Dashboard
</Button>
@@ -83,7 +82,7 @@ export default async function Page() {
<Button
variant="ghost"
size="sm"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
@@ -91,7 +90,7 @@ export default async function Page() {
<Link href="/signup">
<Button
size="sm"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2 dark:bg-white/10 dark:border-white/20 dark:text-white dark:hover:bg-white/20"
>
Começar
</Button>
@@ -107,26 +106,14 @@ export default async function Page() {
{/* Hero Section */}
<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="mx-auto flex max-w-3xl 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">
<RiGithubFill className="size-4 mr-1" />
Projeto Open Source
</Badge>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold">
Suas finanças,
<span className="text-primary"> do seu jeito</span>
</h1>
@@ -207,7 +194,7 @@ export default async function Page() {
className="flex flex-col items-center text-center gap-1.5"
>
<Icon className="size-5" style={{ color: colorVar }} />
<span className="text-2xl md:text-3xl font-medium">
<span className="text-2xl md:text-3xl font-semibold">
{value}
</span>
<span className="text-xs md:text-sm text-muted-foreground">
@@ -229,7 +216,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Conheça as telas
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<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">
@@ -254,7 +241,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
O que tem aqui
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Funcionalidades que importam
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -265,72 +252,34 @@ export default async function Page() {
</AnimateOnScroll>
<AnimateOnScroll>
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3">
{mainFeatures.map((feature) => (
<div className="grid gap-4 md:gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...mainFeatures, ...extraFeatures].map((feature) => (
<Card key={feature.title}>
<CardContent className="pt-5 pb-5 md:pt-6">
<div className="flex flex-col gap-3 md:gap-4">
<CardContent>
<div className="flex items-center gap-3 mb-3">
<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={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 20%, transparent)`,
}}
>
<feature.icon
className="size-[22px] md:size-6"
style={{ color: feature.colorVar }}
className="size-5"
style={{ color: "var(--foreground)" }}
/>
</div>
<div>
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
{feature.title}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
<h3 className="font-semibold text-base leading-tight">
{feature.title}
</h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</CardContent>
</Card>
))}
</div>
</AnimateOnScroll>
<AnimateOnScroll>
<div className="mt-8 md:mt-12">
<h3 className="text-sm font-medium 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-medium 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>
</section>
@@ -346,7 +295,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" />
Mobile
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Use o OpenMonetis no celular sem perder o fluxo
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -396,14 +345,14 @@ export default async function Page() {
{pwaHighlights.map((item) => (
<li key={item.title} className="flex items-start gap-3">
<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={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}}
>
<item.icon
className="size-[15px]"
style={{ color: item.colorVar }}
style={{ color: "var(--foreground)" }}
/>
</div>
<p className="text-sm">
@@ -438,17 +387,19 @@ export default async function Page() {
pré-lançamentos automaticamente para você revisar na inbox.
</p>
<ol className="space-y-3 mb-6">
{companionSteps.map((step, index) => (
{companionSteps.map((step) => (
<li key={step.title} className="flex items-start gap-3">
<span
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
<div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
color: step.colorVar,
backgroundColor: `color-mix(in oklch, ${step.colorVar} 20%, transparent)`,
}}
>
{index + 1}
</span>
<step.icon
className="size-3.5"
style={{ color: "var(--foreground)" }}
/>
</div>
<p className="text-sm">
<span className="font-medium">{step.title}</span>
<span className="text-muted-foreground">
@@ -529,7 +480,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Stack técnica
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
O que roda por baixo
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -545,18 +496,18 @@ export default async function Page() {
<CardContent>
<div className="flex items-start gap-4">
<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={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}}
>
<item.icon
className="size-6"
style={{ color: item.colorVar }}
style={{ color: "var(--foreground)" }}
/>
</div>
<div>
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
{item.title}
</h3>
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
@@ -582,7 +533,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Como usar
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Rode no seu computador
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -617,7 +568,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Para quem é?
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Feito para quem gosta de controle
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -633,18 +584,18 @@ export default async function Page() {
<CardContent>
<div className="flex gap-3 md:gap-4">
<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={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}}
>
<item.icon
className="size-[18px] md:size-5"
style={{ color: item.colorVar }}
style={{ color: "var(--foreground)" }}
/>
</div>
<div>
<h3 className="font-medium mb-1">{item.title}</h3>
<h3 className="font-semibold mb-1">{item.title}</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
{item.description}
</p>
@@ -664,7 +615,7 @@ export default async function Page() {
<div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll>
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Pronto para testar?
</h2>
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
@@ -715,7 +666,7 @@ export default async function Page() {
</div>
<div>
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li>
<Link
@@ -749,7 +700,7 @@ export default async function Page() {
</div>
<div>
<h3 className="font-medium mb-3 md:mb-4">Companion</h3>
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li>
<Link

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
/**
* GET /api/logo/mapping?name={name}
*
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento,
* 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) {
const session = await getOptionalUserSession();
if (!session) {
return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
}
const { searchParams } = new URL(request.url);
const name = searchParams.get("name")?.trim();
if (!name) {
return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
}
const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
const logoUrl = buildLogoDevUrl(domain);
return NextResponse.json({ domain, logoUrl });
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/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";
interface LogoResult {
name: string;
domain: string;
}
interface LogoResultWithUrl extends LogoResult {
logoUrl: string | null;
}
async function searchByStrategy(
q: string,
strategy: "match" | "typeahead",
secretKey: string,
): Promise<LogoResult[]> {
try {
const url = `${LOGO_DEV_SEARCH_URL}?q=${encodeURIComponent(q)}&strategy=${strategy}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secretKey}` },
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
/**
* GET /api/logo/search?q={name}
*
* Proxy seguro para a Logo.dev Brand Search API.
* Faz duas buscas paralelas (match + typeahead) e retorna até 20 resultados únicos.
* Usa LOGO_DEV_SECRET_KEY server-side — nunca exposta ao cliente.
*/
export async function GET(request: Request) {
const session = await getOptionalUserSession();
if (!session) {
return NextResponse.json({ error: "Não autorizado." }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim();
if (!q) {
return NextResponse.json(
{ error: "Parâmetro q obrigatório." },
{ status: 400 },
);
}
const secretKey = process.env.LOGO_DEV_SECRET_KEY;
if (!secretKey) {
return NextResponse.json(
{ error: "Logo.dev não configurado." },
{ status: 503 },
);
}
// Duas buscas paralelas para maximizar resultados (cada uma retorna até 10)
const [matchResults, typeaheadResults] = await Promise.all([
searchByStrategy(q, "match", secretKey),
searchByStrategy(q, "typeahead", secretKey),
]);
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
const seen = new Set<string>();
const merged: LogoResultWithUrl[] = [];
for (const result of [...matchResults, ...typeaheadResults]) {
if (!seen.has(result.domain)) {
seen.add(result.domain);
// 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;
}
}
return NextResponse.json(merged);
}

View File

@@ -10,7 +10,7 @@
:root {
--background: oklch(97.412% 0.00332 67.032);
--foreground: oklch(27% 0.008 45);
--card: oklch(99% 0.002 67);
--card: oklch(100% 0 0);
--card-foreground: var(--foreground);
--popover: oklch(100% 0 0);
--popover-foreground: var(--foreground);
@@ -36,7 +36,7 @@
--destructive: oklch(55% 0.22 27);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(90.274% 0.01362 60.342);
--border: oklch(92.323% 0.01276 63.703);
--input: var(--border);
--ring: var(--primary);
@@ -57,10 +57,6 @@
--data-4: oklch(74% 0.18 55); /* âmbar */
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */
--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-foreground: var(--foreground);
@@ -71,7 +67,7 @@
--sidebar-border: oklch(91% 0.004 70);
--sidebar-ring: var(--primary);
--radius: 0.625rem;
--radius: 0.7rem;
--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);
@@ -94,7 +90,7 @@
.dark {
--background: oklch(18% 0.004 55);
--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);
--popover: oklch(24% 0.004 55);
--popover-foreground: var(--foreground);
@@ -120,7 +116,7 @@
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(31% 0.004 55);
--border: oklch(28% 0.0035 55);
--input: var(--border);
--ring: var(--primary);
@@ -141,10 +137,6 @@
--data-4: oklch(81% 0.18 55);
--data-5: oklch(84% 0.16 68);
--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-foreground: var(--foreground);
@@ -155,7 +147,7 @@
--sidebar-border: oklch(30% 0.004 55);
--sidebar-ring: var(--primary);
--radius: 0.625rem;
--radius: 0.7rem;
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
@@ -177,7 +169,7 @@
}
@theme inline {
--default-font-family: var(--font-america);
--default-font-family: var(--font-inter);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -354,3 +346,22 @@
justify-content: flex-end;
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

@@ -4,7 +4,7 @@ import { QueryProvider } from "@/shared/components/providers/query-provider";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css";
import { america } from "@/public/fonts/font_index";
import { inter } from "@/public/fonts/font_index";
export const metadata: Metadata = {
title: {
@@ -24,19 +24,23 @@ export default function RootLayout({
<html
data-scroll-behavior="smooth"
lang="pt-BR"
className={`${america.variable} ${america.className} `}
className={`${inter.variable}`}
suppressHydrationWarning
>
<head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
<script
defer
src="https://umami.felipecoutinho.com/script.js"
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
data-domains="openmonetis.com"
/>
{process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
<script
defer
src={`${process.env.UMAMI_URL}/script.js`}
data-website-id={process.env.UMAMI_WEBSITE_ID}
{...(process.env.UMAMI_DOMAINS
? { "data-domains": process.env.UMAMI_DOMAINS }
: {})}
/>
)}
</head>
<body className="antialiased" suppressHydrationWarning>
<body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
<QueryProvider>
<Suspense>{children}</Suspense>

View File

@@ -32,57 +32,69 @@ export const user = pgTable("user", {
}).notNull(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
mode: "date",
withTimezone: true,
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
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", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt", {
mode: "date",
withTimezone: true,
}).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt", {
mode: "date",
withTimezone: true,
}).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("session_user_id_idx").on(table.userId),
}),
);
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
@@ -104,24 +116,30 @@ export const verification = pgTable("verification", {
// ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", {
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("publicKey").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credentialID").notNull(),
counter: integer("counter").notNull(),
deviceType: text("deviceType").notNull(),
backedUp: boolean("backedUp").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("publicKey").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credentialID").notNull(),
counter: integer("counter").notNull(),
deviceType: text("deviceType").notNull(),
backedUp: boolean("backedUp").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
},
(table) => ({
userIdIdx: index("passkey_user_id_idx").on(table.userId),
}),
});
);
export const userPreferences = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
@@ -157,39 +175,30 @@ export const userPreferences = pgTable("preferencias_usuario", {
// ===================== PUBLIC TABLES =====================
export const financialAccounts = pgTable(
"contas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const financialAccounts = pgTable("contas", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
});
export const categories = pgTable(
"categorias",
@@ -248,14 +257,6 @@ export const payers = pgTable(
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
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 +286,12 @@ export const payerShares = pgTable(
table.payerId,
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),
}),
);
@@ -317,10 +324,7 @@ export const cards = pgTable(
}),
},
(table) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on(
table.userId,
table.status,
),
accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
}),
);
@@ -387,26 +391,33 @@ export const budgets = pgTable(
userIdCategoryIdPeriodUnique: uniqueIndex(
"orcamentos_user_id_categoria_id_periodo_key",
).on(table.userId, table.categoryId, table.period),
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
}),
);
export const notes = pgTable("anotacoes", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("titulo"),
description: text("descricao"),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
tasks: text("tasks"), // JSON stringificado com array de tarefas
archived: boolean("arquivada").notNull().default(false),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const notes = pgTable(
"anotacoes",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("titulo"),
description: text("descricao"),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
tasks: text("tasks"), // JSON stringificado com array de tarefas
archived: boolean("arquivada").notNull().default(false),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
}),
);
export const savedInsights = pgTable(
"insights_salvos",
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
.defaultNow(),
},
(table) => ({
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
}),
);
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
table.userId,
table.createdAt,
),
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
table.transactionId,
),
}),
);
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
userIdNotificationKeyUnique: uniqueIndex(
"dashboard_notification_states_user_id_key_unique",
).on(table.userId, table.notificationKey),
userIdArchivedAtIdx: index(
"dashboard_notification_states_user_id_archived_idx",
).on(table.userId, table.archivedAt),
}),
);
@@ -597,10 +607,14 @@ export const installmentAnticipations = pgTable(
.defaultNow(),
},
(table) => ({
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
table.seriesId,
),
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,
),
}),
);
@@ -700,6 +714,12 @@ export const transactions = pgTable(
table.cardId,
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
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId)
@@ -721,6 +741,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
installmentAnticipations: many(installmentAnticipations),
apiTokens: many(apiTokens),
inboxItems: many(inboxItems),
establishmentLogos: many(establishmentLogos),
}));
export const accountRelations = relations(account, ({ one }) => ({
@@ -904,19 +925,25 @@ export const installmentAnticipationsRelations = relations(
// ===================== ATTACHMENTS =====================
export const attachments = pgTable("anexos", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileKey: text("chave_arquivo").notNull().unique(),
fileName: text("nome_arquivo").notNull(),
fileSize: integer("tamanho_bytes").notNull(),
mimeType: text("mime_type").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
});
export const attachments = pgTable(
"anexos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileKey: text("chave_arquivo").notNull().unique(),
fileName: text("nome_arquivo").notNull(),
fileSize: integer("tamanho_bytes").notNull(),
mimeType: text("mime_type").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index("anexos_user_id_idx").on(table.userId),
}),
);
export const transactionAttachments = pgTable(
"lancamento_anexos",
@@ -952,9 +979,31 @@ export const importCategoryMappings = pgTable(
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
table.categoryId,
),
}),
);
export const establishmentLogos = pgTable(
"establishment_logos",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
nameKey: text("name_key").notNull(),
domain: text("domain").notNull(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.nameKey] }),
}),
);
export type EstablishmentLogo = typeof establishmentLogos.$inferSelect;
export type User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect;
@@ -1004,3 +1053,13 @@ export const transactionAttachmentsRelations = relations(
export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
export const establishmentLogosRelations = relations(
establishmentLogos,
({ one }) => ({
user: one(user, {
fields: [establishmentLogos.userId],
references: [user.id],
}),
}),
);

View File

@@ -99,7 +99,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) {
throw new Error(
"Payer com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
"Pessoa com papel administrador não encontrado. Crie um pessoa admin antes de definir um saldo inicial.",
);
}
@@ -299,7 +299,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) {
throw new Error(
"Payer administrador não encontrado. Por favor, crie um pagador admin.",
"Pessoa administrador não encontrado. Por favor, crie um pessoa admin.",
);
}

View File

@@ -1,4 +1,5 @@
"use client";
import {
RiArrowLeftRightLine,
RiDeleteBin5Line,
@@ -47,6 +48,13 @@ export function AccountCard({
}: AccountCardProps) {
const isInactive = status?.toLowerCase() === "inativa";
const balanceColor =
balance > 0
? "text-success"
: balance < 0
? "text-destructive"
: "text-foreground";
const actions = [
{
label: "editar",
@@ -75,76 +83,90 @@ export function AccountCard({
].filter((action) => typeof action.onClick === "function");
return (
<Card className={cn("h-full w-full gap-0", className)}>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
{icon ? (
<div
className={cn(
"flex items-center justify-center",
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
)}
>
{icon}
<Card className={cn("flex w-full flex-col p-6", className)}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"flex shrink-0 items-center justify-center",
isInactive && "grayscale opacity-40",
)}
>
{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>
) : null}
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip>
<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>
)}
<p className="text-xs text-muted-foreground">{status}</p>
</div>
</div>
<div className="space-y-2">
<MoneyValues amount={balance} className="text-3xl" />
<p className="text-sm text-muted-foreground">{accountType}</p>
<p className="text-xs text-muted-foreground">{accountType}</p>
</div>
<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>
</CardContent>
{actions.length > 0 ? (
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary",
)}
aria-label={`${label} conta`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
<CardFooter className="flex flex-wrap gap-4 p-0 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary",
)}
aria-label={`${label} conta`}
>
{icon}
{label}
</button>
))}
</CardFooter>
</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 =
mode === "create"
? "Cadastre uma nova conta para organizar seus lançamentos."
: "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) => {
if (!open && logoDialogOpen) {

View File

@@ -68,7 +68,7 @@ export function AccountStatementCard({
</div>
) : null}
<div className="min-w-0">
<h2 className="truncate text-sm font-medium text-foreground">
<h2 className="truncate text-sm font-semibold text-foreground">
{accountName}
</h2>
<p className="text-xs text-muted-foreground">
@@ -81,12 +81,12 @@ export function AccountStatementCard({
{/* Linha 2 — saldo final (hero) */}
<div className="space-y-4">
<p className="text-sm font-medium text-muted-foreground ">
<p className="text-sm text-muted-foreground ">
Saldo ao final do período
</p>
<MoneyValues
amount={currentBalance}
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
className="text-3xl leading-none tracking-tighter sm:text-2xl"
/>
<div className="flex items-center gap-2">
<Badge
@@ -123,7 +123,7 @@ export function AccountStatementCard({
<MetaItem
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">
{formatCurrency(totalExpenses)}

View File

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

View File

@@ -8,7 +8,7 @@ import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AccountSummaryData = {
type AccountSummaryData = {
openingBalance: number;
currentBalance: number;
totalIncomes: number;

View File

@@ -69,9 +69,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
<span className="text-xs font-medium text-muted-foreground/60">
PDF Protegido
</span>
<span className="text-xs text-muted-foreground/60">PDF Protegido</span>
</div>
);
}
@@ -153,7 +151,7 @@ export function AttachmentGridItem({
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm font-medium leading-tight text-foreground">
<p className="truncate text-sm font-semibold leading-tight text-foreground">
{attachment.fileName}
</p>
</TooltipTrigger>
@@ -180,25 +178,21 @@ export function AttachmentGridItem({
{attachment.transactionName}
</TooltipContent>
</Tooltip>
<span
className={cn(
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
)}
>
<span className={cn("shrink-0 text-sm font-medium tracking-tighter")}>
{formatCurrency(amount)}
</span>
</div>
{/* Footer: Tamanho + Botão Detalhes */}
<div className="mt-auto flex items-center justify-between border-t pt-3">
<span className="text-xs font-medium text-muted-foreground/70">
<span className="text-xs text-muted-foreground/70">
{formatBytes(attachment.fileSize)}
</span>
<button
type="button"
onClick={onDetails}
disabled={isLoadingDetails}
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
className="text-xs text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
>
{isLoadingDetails ? "Carregando..." : "Detalhes"}
</button>

View File

@@ -105,7 +105,7 @@ export function AttachmentPreview({
>
<RiArrowLeftSLine className="size-4" />
</Button>
<span className="select-none text-xs text-muted-foreground tabular-nums">
<span className="select-none text-xs text-muted-foreground">
{currentIndex + 1} / {attachments.length}
</span>
<Button

View File

@@ -19,8 +19,6 @@ import { TransactionDetailsDialog } from "@/features/transactions/components/dia
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/empty-state";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui";
@@ -143,14 +141,6 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
return (
<div className="w-full space-y-6">
<PageDescription
icon={<RiAttachmentLine className="size-5" />}
title="Anexos"
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
/>
<MonthNavigation />
<Card>
<CardContent>
{attachments.length === 0 ? (

View File

@@ -1,25 +1,12 @@
import type { PropsWithChildren } from "react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import AuthSidebar from "./auth-sidebar";
export function AuthCardShell({ children }: PropsWithChildren) {
return (
<Card className="relative overflow-hidden p-0">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<DotPattern
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>
<Card className="overflow-hidden border-primary/10 p-0 shadow-lg">
<CardContent className="grid p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
<div className="flex md:rounded-l-4xl">{children}</div>
<AuthSidebar />
</CardContent>
</Card>

View File

@@ -8,7 +8,7 @@ interface AuthHeaderProps {
export function AuthHeader({ title, description }: AuthHeaderProps) {
return (
<div className={cn("flex flex-col gap-2.5")}>
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
{description ? (

View File

@@ -15,7 +15,6 @@ import type { Budget } from "./types";
interface BudgetCardProps {
budget: Budget;
periodLabel: string;
onEdit: (budget: Budget) => void;
onRemove: (budget: Budget) => void;
}
@@ -29,81 +28,88 @@ const buildUsagePercent = (spent: number, limit: number) => {
};
const formatCategoryName = (budget: Budget) =>
budget.category?.name ?? "Category removida";
budget.category?.name ?? "Categoria removida";
export function BudgetCard({
budget,
periodLabel,
onEdit,
onRemove,
}: BudgetCardProps) {
export function BudgetCard({ budget, onEdit, onRemove }: BudgetCardProps) {
const { amount: limit, spent } = budget;
const exceeded = spent > limit && limit >= 0;
const difference = Math.abs(spent - limit);
const usagePercent = buildUsagePercent(spent, limit);
const remaining = Math.max(limit - spent, 0);
return (
<Card className="flex h-full flex-col">
<CardContent className="flex h-full flex-col gap-4">
<div className="flex items-start gap-3">
<CategoryIconBadge
icon={budget.category?.icon ?? undefined}
name={formatCategoryName(budget)}
size="lg"
<Card className="flex w-full flex-col p-6">
<div className="flex items-center gap-2">
<CategoryIconBadge
icon={budget.category?.icon ?? undefined}
name={formatCategoryName(budget)}
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-medium leading-tight">
{formatCategoryName(budget)}
</h3>
<p className="text-xs text-muted-foreground">
Orçamento de {periodLabel}
</p>
</div>
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Gasto até agora</span>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Orçamento</span>
<MoneyValues
amount={spent}
className={cn(exceeded && "text-destructive")}
amount={limit}
className="text-sm font-semibold text-foreground"
/>
</div>
<Progress
value={usagePercent}
className={cn("h-2", exceeded && "bg-destructive/20!")}
/>
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm">
<span className="text-muted-foreground">Limite</span>
<MoneyValues amount={limit} className="text-foreground" />
</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 className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Gasto</span>
<MoneyValues
amount={spent}
className={cn(
"text-sm font-semibold",
exceeded ? "text-destructive" : "text-primary",
)}
/>
</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>
<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
type="button"
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
</button>
{budget.category && (
<Link
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
</Link>
@@ -111,7 +117,7 @@ export function BudgetCard({
<button
type="button"
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
</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 =
mode === "create"
? "Defina um limite de gastos para acompanhar suas despesas."
: "Atualize os detalhes do orçamento selecionado.";
const submitLabel =
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
const disabled = categories.length === 0;
const parsedAmount = Number.parseFloat(formState.amount);
const sliderValue = Number.isFinite(parsedAmount)

View File

@@ -19,14 +19,12 @@ interface BudgetsPageProps {
budgets: Budget[];
categories: BudgetCategory[];
selectedPeriod: string;
periodLabel: string;
}
export function BudgetsPage({
budgets,
categories,
selectedPeriod,
periodLabel,
}: BudgetsPageProps) {
const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
@@ -137,7 +135,6 @@ export function BudgetsPage({
<BudgetCard
key={budget.id}
budget={budget}
periodLabel={periodLabel}
onEdit={handleEdit}
onRemove={handleRemoveRequest}
/>
@@ -168,7 +165,7 @@ export function BudgetsPage({
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Esta ação remove o limite configurado para a categoria selecionada."
confirmLabel="Remover orçamento"
confirmLabel="Remover"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
@@ -179,7 +176,7 @@ export function BudgetsPage({
onOpenChange={setDuplicateOpen}
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."
confirmLabel="Copiar orçamentos"
confirmLabel="Copiar"
pendingLabel="Copiando..."
onConfirm={handleDuplicateConfirm}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import { parsePeriod } from "@/shared/utils/period";
const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
const PAYMENT_PREFIX = "Pagamento fatura - ";
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
@@ -88,19 +89,28 @@ export const fetchCalendarData = async ({
const transactionData = mapTransactionsData(transactionRows);
const events: CalendarEvent[] = [];
// Totais por cartão para exibir no vencimento
const cardTotals = new Map<string, number>();
for (const item of transactionData) {
if (!item.cardId || item.period !== period) {
continue;
}
if (!item.cardId || item.period !== period) continue;
const amount = Math.abs(item.amount ?? 0);
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) {
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;
// Para boletos, exibir apenas na data de vencimento
if (isBoleto) {
if (
item.dueDate &&
@@ -114,7 +124,6 @@ export const fetchCalendarData = async ({
});
}
} else {
// Para outros tipos de lançamento, exibir na data de compra
const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
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) {
if (!cardTotals.has(card.id)) {
continue;
}
if (!cardTotals.has(card.id)) continue;
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
if (Number.isNaN(dueDayNumber)) {
continue;
}
if (Number.isNaN(dueDayNumber)) continue;
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
const dueDateKey = formatDateKey(
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`,
type: "card",
date: dueDateKey,
@@ -156,17 +202,20 @@ export const fetchCalendarData = async ({
status: card.status,
logo: card.logo ?? null,
totalDue: cardTotals.get(card.id) ?? null,
isPaid,
paymentDate,
},
});
}
const typePriority: Record<CalendarEvent["type"], number> = {
transaction: 0,
installment: 0,
boleto: 1,
card: 2,
};
events.sort((a, b) => {
allEvents.sort((a, b) => {
if (a.date === b.date) {
return typePriority[a.type] - typePriority[b.type];
}
@@ -182,7 +231,7 @@ export const fetchCalendarData = async ({
const estabelecimentos = await fetchRecentEstablishments(userId);
return {
events,
events: allEvents,
formOptions: {
payerOptions: optionSets.payerOptions,
splitPayerOptions: optionSets.splitPayerOptions,

View File

@@ -194,12 +194,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 =
mode === "create"
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
: "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) => {
if (!open && logoDialogOpen) {

View File

@@ -84,39 +84,11 @@ export function CardItem({
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand);
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",
},
];
const hasMetrics = limitTotal !== null && used !== null && available !== null;
return (
<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 flex-1 items-center gap-2">
{logoPath ? (
@@ -135,8 +107,8 @@ export function CardItem({
) : null}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-medium text-foreground sm:text-base">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-foreground">
{name}
</h3>
{note ? (
@@ -166,14 +138,14 @@ export function CardItem({
</div>
{brandAsset ? (
<div className="flex items-center justify-center py-1">
<div className="flex items-center justify-center py-2">
<Image
src={brandAsset}
alt={`Bandeira ${brand}`}
width={36}
height={36}
className={cn(
"h-5 w-auto rounded",
"h-4 w-auto rounded",
isInactive && "grayscale opacity-40",
)}
/>
@@ -185,56 +157,65 @@ export function CardItem({
)}
</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>
Fecha dia{" "}
<span className="font-medium text-foreground">
{formatDay(closingDay)}
Fecha em{" "}
<span className="font-semibold text-foreground">
dia {formatDay(closingDay)}
</span>
</span>
<span>
Vence dia{" "}
<span className="font-medium text-foreground">
{formatDay(dueDay)}
Vence em{" "}
<span className="font-semibold text-foreground">
dia {formatDay(dueDay)}
</span>
</span>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 px-0">
{metrics ? (
<CardContent className="flex flex-1 flex-col gap-4 px-0">
{hasMetrics &&
available !== null &&
used !== null &&
limitTotal !== null ? (
<>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-1">
<p className="text-sm font-medium text-foreground">
<MoneyValues amount={metrics[0].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[0].label}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Disponível</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[1].label}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite total
</span>
<MoneyValues
amount={limitTotal}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col items-end gap-1">
<p className="text-sm font-medium text-foreground">
<MoneyValues amount={metrics[2].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[2].label}
</span>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Em uso</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-primary"
/>
</div>
</div>
<Progress value={usagePercent} className="h-3" />
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
@@ -243,21 +224,31 @@ export function CardItem({
)}
</CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
{actions.map(({ label, icon, onClick, className }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
className,
)}
>
{icon}
{label}
</button>
))}
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
>
<RiPencilLine className="size-4" aria-hidden />
editar
</button>
<button
type="button"
onClick={onInvoice}
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
>
<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>
</Card>
);

View File

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

View File

@@ -3,7 +3,7 @@ import { cards, financialAccounts, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { loadLogoOptions } from "@/shared/lib/logo/options";
export type CardData = {
type CardData = {
id: string;
name: string;
brand: string;

View File

@@ -11,6 +11,7 @@ import { useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCategoryAction } from "@/features/categories/actions";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
@@ -30,10 +31,10 @@ import {
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
type CategoryType,
} from "@/shared/lib/categories/constants";
import { CategoryDialog } from "./category-dialog";
import { CategoryIconBadge } from "./category-icon-badge";
import type { Category, CategoryType } from "./types";
import type { Category } from "./types";
const CATEGORIAS_PROTEGIDAS = [
"Transferência interna",
@@ -183,7 +184,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
<TableCell className="font-medium">
<Link
href={`/categories/${category.id}`}
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline"
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline font-semibold"
>
{category.name}
<RiExternalLinkLine
@@ -249,7 +250,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
confirmLabel="Remover categoria"
confirmLabel="Remover"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}

View File

@@ -1,11 +1,10 @@
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
type CategorySummary = {
id: string;
@@ -33,33 +32,6 @@ export function CategoryDetailHeader({
percentageChange,
transactionCount,
}: CategoryDetailHeaderProps) {
const isIncrease =
typeof percentageChange === "number" && percentageChange > 0;
const isDecrease =
typeof percentageChange === "number" && percentageChange < 0;
const variationColor =
category.type === "receita"
? isIncrease
? "text-success"
: isDecrease
? "text-destructive"
: "text-muted-foreground"
: isIncrease
? "text-destructive"
: isDecrease
? "text-success"
: "text-muted-foreground";
const variationIcon =
isIncrease || isDecrease ? (
isIncrease ? (
<RiArrowUpSFill className="size-4" aria-hidden />
) : (
<RiArrowDownSFill className="size-4" aria-hidden />
)
) : null;
const variationLabel =
typeof percentageChange === "number"
? formatPercentage(percentageChange, {
@@ -80,7 +52,7 @@ export function CategoryDetailHeader({
size="lg"
/>
<div className="space-y-2">
<h1 className="text-xl font-medium leading-tight">
<h1 className="text-xl font-semibold leading-tight">
{category.name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
@@ -99,7 +71,7 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-medium">
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)}
</p>
</div>
@@ -107,7 +79,7 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-medium text-muted-foreground">
<p className="mt-1 text-lg font-semibold text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
@@ -115,15 +87,13 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior
</p>
<div
className={cn(
"mt-1 flex items-center gap-1 text-xl font-medium",
variationColor,
)}
>
{variationIcon}
<span>{variationLabel}</span>
</div>
<PercentageChangeIndicator
value={percentageChange}
label={variationLabel}
positiveTrend={category.type === "receita" ? "up" : "down"}
className="mt-1 gap-1 text-lg font-semibold"
iconClassName="size-4"
/>
</div>
</div>
</div>

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