33 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
265 changed files with 6614 additions and 6198 deletions

View File

@@ -3,10 +3,10 @@
# ============================================ # ============================================
# === Database === # === Database ===
# PostgreSQL local (Docker): use host "db" # Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
# PostgreSQL local (sem Docker): use host "localhost" # Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
# PostgreSQL remoto: use URL completa do provider # 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 # Credenciais do PostgreSQL (apenas para Docker local) - Alterar
POSTGRES_USER=openmonetis POSTGRES_USER=openmonetis
@@ -48,7 +48,6 @@ GOOGLE_CLIENT_SECRET=
# Umami: https://umami.is — self-hosted ou cloud # Umami: https://umami.is — self-hosted ou cloud
UMAMI_URL= UMAMI_URL=
UMAMI_WEBSITE_ID= UMAMI_WEBSITE_ID=
# Domínios rastreados (ex: openmonetis.com) — corresponde ao data-domains do script
UMAMI_DOMAINS= UMAMI_DOMAINS=
# === AI Providers (Opcional) === # === AI Providers (Opcional) ===
@@ -56,3 +55,8 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=

View File

@@ -7,6 +7,89 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [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 ## [2.3.7] - 2026-04-11
### Adicionado ### Adicionado
@@ -15,6 +98,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo - 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 - 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) - 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 ### Alterado
@@ -32,6 +116,15 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
- 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` - 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" - 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 - 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 ## [2.3.6] - 2026-04-09

View File

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

View File

@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
# Nota: a integração Logo.dev não precisa mais de build args. O token
# `LOGO_DEV_TOKEN` é lido em runtime no servidor — basta configurá-lo no
# host (Coolify, Railway, etc.) junto com `LOGO_DEV_SECRET_KEY`.
# Build da aplicação Next.js # Build da aplicação Next.js
RUN pnpm build RUN pnpm build

263
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. > **⚠️ 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.7-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/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -32,9 +32,9 @@
## 📖 Índice ## 📖 Índice
- [Sobre o Projeto](#-sobre-o-projeto) - [Sobre o Projeto](#-sobre-o-projeto)
- [Instalação via Script](#-instalação-via-script) - [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
- [Preparar o servidor (Ubuntu 24.04)](#-preparar-o-servidor-ubuntu-2404) - [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
- [Início Rápido (manual)](#-início-rápido) - [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
- [Scripts Disponíveis](#-scripts-disponíveis) - [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker) - [Docker](#-docker)
- [Backup](#-backup) - [Backup](#-backup)
@@ -103,107 +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:
### 🖥️ Preparar o servidor (Ubuntu 24.04) | | 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 |
Se você está num **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Node.js, Docker ou pnpm instalados, use o script de preparação antes de continuar. ---
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões é necessário testar ou ajustar o script. ### Perfil 1 — Usar (self-hosting)
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
```bash
# 1. Baixe o compose
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
# 2. Suba tudo
docker compose up -d
```
Acesse em: `http://localhost:3000`
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir:
```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
```
Veja todas as variáveis disponíveis em [Variáveis de Ambiente](#-variáveis-de-ambiente).
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
```bash
docker compose up -d app
```
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
sudo sh install-deps.sh sudo sh install-deps.sh
``` ```
O script instala (e pula o que já estiver presente): > Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
| Ferramenta | Como instala | #### Atualizando (Perfil 1)
|---|---|
| `git`, `curl`, `ca-certificates` | apt |
| Docker Engine + Docker Compose | Repositório oficial do Docker |
| Homebrew | Script oficial (como usuário não-root) |
| Node.js 22 | Via Homebrew |
| pnpm | Via corepack |
Após a conclusão, adiciona o usuário atual ao grupo `docker` — faça logout/login para ativar. Em seguida, prossiga com o `setup.mjs` abaixo.
---
**Pré-requisito:** Node.js 22+
```bash ```bash
# Mac / Linux / WSL pnpm docker:update
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs # ou equivalente:
docker compose pull && docker compose up -d
# Windows (PowerShell)
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
``` ```
O script irá: O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
- Verificar Node, pnpm, Git e Docker
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
- Gerar o `BETTER_AUTH_SECRET` automaticamente
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
- Clonar o repositório, instalar dependências e aplicar o schema
--- ---
## 🚀 Início Rápido (manual) ### Perfil 2 — Desenvolver
### Pré-requisitos Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
- Node.js 22+ e pnpm **Requisitos:** Docker + Node.js 22+ + pnpm
- Docker e Docker Compose
### Passo a Passo ```bash
# 1. Clone o repositório
git clone https://github.com/felipegcoutinho/openmonetis.git
cd openmonetis
1. **Clone e instale** # 2. Instale as dependências
pnpm install
```bash # 3. Configure o ambiente
git clone https://github.com/felipegcoutinho/openmonetis.git cp .env.example .env
cd openmonetis # O DATABASE_URL já vem com host "localhost" (correto para dev local).
pnpm install # Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
```
2. **Configure o `.env`** # 4. Suba o banco
pnpm docker:db
```bash # 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
cp .env.example .env pnpm db:extensions
```
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`: # 6. Aplique o schema no banco (apenas no primeiro setup)
pnpm db:push
```env # 7. Inicie o app com hot-reload
# Banco local (Docker): use host "localhost" pnpm dev
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db ```
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider Acesse em: `http://localhost:3000`
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32 Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
BETTER_AUTH_URL=http://localhost:3000
```
3. **Suba o banco de dados** (pule se estiver usando banco remoto) #### Atualizando (Perfil 2)
```bash ```bash
docker compose up db -d git pull
pnpm db:extensions pnpm install # instala dependências novas, se houver
``` pnpm db:push # aplica mudanças de schema, se houver
```
4. **Execute as migrations e inicie** O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
```bash
pnpm db:push
pnpm dev
```
5. Acesse `http://localhost:3000`
> **Docker completo** (app + banco em containers): use `pnpm docker:up:local` ao invés dos passos 3-4.
--- ---
@@ -238,65 +253,36 @@ pnpm backup # Backup completo do banco (ver seção Backup)
### Docker ### Docker
```bash ```bash
pnpm docker:up:local # Sobe app + banco PostgreSQL juntos (imagem do Hub) pnpm docker:up # Sobe app (Docker Hub) + banco em background
pnpm docker:up # Sobe apenas o app com build local pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
pnpm docker:up:d # Sobe apenas o app com build local em background
pnpm docker:up:db # Sobe apenas o banco em background
pnpm docker:down # Para e remove os containers pnpm docker:down # Para e remove os containers
pnpm docker:down:volumes # Para containers e remove volumes (⚠️ apaga dados!)
pnpm docker:logs # Logs em tempo real (todos os containers) pnpm docker:logs # Logs em tempo real (todos os containers)
pnpm docker:logs:app # Logs do container da aplicação pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
pnpm docker:logs:db # Logs do container do banco
pnpm docker:restart # Reinicia todos os containers
pnpm docker:rebuild # Rebuild completo forçando recriação
``` ```
--- ---
## 🐳 Docker ## 🐳 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)
### Modos de uso Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
**Modo 1 — App + banco local (recomendado para self-hosting)**
Puxa a imagem pronta do Docker Hub e sobe app + banco juntos. Não precisa de Node.js instalado.
```bash ```bash
# 1. Baixar o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
# 2. Criar o .env (copie o .env.example como referência)
# DATABASE_URL=postgresql://openmonetis:SUA_SENHA@db:5432/openmonetis_db
# POSTGRES_PASSWORD=SUA_SENHA
# BETTER_AUTH_SECRET=string-longa-aleatoria
# BETTER_AUTH_URL=http://localhost:3000
# 3. Subir
docker compose --profile local up
# ou, se tiver o projeto clonado:
pnpm docker:up:local
``` ```
**Modo 2 — App com banco remoto** 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).
Use quando o banco está em um provider externo (Supabase, Neon, Railway...). ### Banco remoto (Supabase, Neon, Railway...)
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
```bash ```bash
# DATABASE_URL deve apontar para o banco remoto no .env docker compose up -d app
docker compose up
```
**Modo 3 — Build local (desenvolvimento)**
Builda a imagem localmente a partir do código-fonte.
```bash
pnpm docker:up # app apenas (banco separado)
pnpm docker:up:db # sobe o banco em background
``` ```
### Comandos úteis ### Comandos úteis
@@ -309,7 +295,7 @@ docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Bac
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
``` ```
### Customizando Portas ### Customizando portas
```env ```env
APP_PORT=3001 # Padrão: 3000 APP_PORT=3001 # Padrão: 3000
@@ -404,13 +390,53 @@ S3_BUCKET=
--- ---
## 🏷️ Logos de Estabelecimentos (Logo.dev)
O app exibe logos automáticos de marcas na coluna de estabelecimentos nos lançamentos. A integração usa a [Logo.dev](https://www.logo.dev) e é opcional — sem ela, o app exibe as iniciais coloridas normalmente.
### Variáveis
```env
LOGO_DEV_TOKEN=pk_... # token público (obrigatório para exibir logos)
LOGO_DEV_SECRET_KEY=sk_... # chave secreta (obrigatório para o picker de busca)
```
> **Atualizando da v2.4.1 ou anterior:** a variável foi renomeada de `NEXT_PUBLIC_LOGO_DEV_TOKEN` para `LOGO_DEV_TOKEN`. Renomeie no seu `.env` (ou nas variáveis do Coolify/host) e remova o secret homônimo do GitHub Actions — ele não é mais usado. Não há outra etapa de migração.
### Como configurar
Ambas as variáveis são lidas em **runtime** pelo servidor Next.js. Não há mais nenhuma etapa no CI nem `--build-arg` no Docker.
**Self-hosted via Docker Hub (Coolify, Railway, etc.):**
1. Adicione `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` nas variáveis de ambiente do host
2. Reinicie o container — pronto
**Desenvolvimento local:**
Adicione as duas no `.env` e rode `pnpm dev`.
### Como usar
Após configurado, passe o mouse sobre o avatar de qualquer estabelecimento nos lançamentos — um ícone de lápis aparece. Clique para abrir o picker, busque pelo nome da marca e selecione o logo desejado. O mapeamento fica salvo por usuário no banco.
### Arquitetura
O token **nunca chega ao cliente**. O servidor constrói a URL `https://img.logo.dev/{domain}?token=...` nos endpoints `/api/logo/mapping` e `/api/logo/search`, e o cliente apenas consome a URL pronta. Um Context Provider (`LogoDevProvider`) propaga a flag `enabled` para os componentes que decidem se renderizam o picker.
---
## 🔐 Variáveis de Ambiente ## 🔐 Variáveis de Ambiente
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 ### Obrigatórias
```env ```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 DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32 BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
@@ -447,6 +473,11 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=
``` ```
--- ---

View File

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

View File

@@ -1,138 +1,52 @@
# Docker Compose para Next.js + PostgreSQL
name: openmonetis 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: services:
# ============================================
# Serviço: PostgreSQL (Banco de dados local)
# Ativado apenas com: --profile local
# ============================================
db: db:
profiles: ["local"]
image: postgres:18-alpine image: postgres:18-alpine
container_name: openmonetis_postgres container_name: openmonetis_postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-openmonetis} POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db} POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
PGDATA: /var/lib/postgresql/data
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - 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: healthcheck:
test: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}"]
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s 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: app:
build: .
image: felipegcoutinho/openmonetis:latest image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app container_name: openmonetis_app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
env_file:
- path: .env
required: false
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
# Banco local: use host "db" | Banco remoto: URL completa do provider BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
DATABASE_URL: ${DATABASE_URL}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
required: false required: false
healthcheck: healthcheck:
test: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
[
"CMD",
"wget",
"--quiet",
"--tries=1",
"--spider",
"http://localhost:3000/api/health",
]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
# ============================================
# Volumes
# ============================================
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local

View File

@@ -1,15 +1,26 @@
#!/bin/sh #!/bin/sh
echo "Habilitando extensão pgcrypto..."
node -e "
const { Client } = require('/app/migrate/node_modules/pg');
const c = new Client({ connectionString: process.env.DATABASE_URL });
c.connect()
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
.then(() => c.end())
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
"
echo "Rodando migrations..." echo "Rodando migrations..."
RETRIES=5 MIGRATED=0
until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do for i in 1 2 3 4 5; do
RETRIES=$((RETRIES - 1)) if NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push; then
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)" MIGRATED=1
break
fi
echo "Tentativa $i/5 falhou. Aguardando 5s..."
sleep 5 sleep 5
done done
if [ "$RETRIES" -eq 0 ]; then [ "$MIGRATED" -eq 0 ] && echo "Aviso: migrations não foram aplicadas."
echo "Aviso: migrations nao foram aplicadas"
fi
exec "$@" 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, "when": 1774891206703,
"tag": "0024_petite_lucky_pierre", "tag": "0024_petite_lucky_pierre",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.3.7", "version": "2.4.2",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -11,62 +11,37 @@
"lint": "biome check .", "lint": "biome check .",
"lint:deadcode": "knip --reporter compact", "lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh", "env:setup": "node setup.mjs",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts", "db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs", "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up": "docker compose up -d",
"// --- Docker ---": "---", "//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
"docker:db": "docker compose up -d db",
"docker:up:local": "docker compose --profile local up", "//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
"//docker:up:local": "Sobe app + banco PostgreSQL local juntos (imagem do Docker Hub)",
"docker:up": "docker compose up --build",
"//docker:up": "Sobe apenas o app com build local (banco deve estar rodando separado)",
"docker:up:d": "docker compose up --build -d",
"//docker:up:d": "Sobe apenas o app com build local em background (detached)",
"docker:up:db": "docker compose up -d db",
"//docker:up:db": "Sobe apenas o banco PostgreSQL em background",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"//docker:down": "Para e remove os containers", "//docker:down": "Para e remove os containers",
"docker:down:volumes": "docker compose down -v",
"//docker:down:volumes": "Para containers e remove volumes (APAGA os dados!)",
"docker:logs": "docker compose logs -f", "docker:logs": "docker compose logs -f",
"//docker:logs": "Acompanha logs de todos os containers em tempo real", "//docker:logs": "Acompanha logs de todos os containers em tempo real",
"docker:update": "docker compose pull && docker compose up -d",
"docker:logs:app": "docker compose logs -f app", "//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
"//docker:logs:app": "Acompanha logs do container da aplicação", "backup": "bash scripts/backup.sh",
"mockup": "tsx scripts/mock-data.ts"
"docker:logs:db": "docker compose logs -f db",
"//docker:logs:db": "Acompanha logs do container do banco",
"docker:restart": "docker compose restart",
"//docker:restart": "Reinicia todos os containers",
"docker:rebuild": "docker compose up --build --force-recreate",
"//docker:rebuild": "Rebuild completo forçando recriação dos containers",
"backup": "bash scripts/backup.sh"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.68", "@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/google": "^3.0.61", "@ai-sdk/google": "^3.0.64",
"@ai-sdk/openai": "^3.0.52", "@ai-sdk/openai": "^3.0.53",
"@aws-sdk/client-s3": "^3.1027.0", "@aws-sdk/client-s3": "^3.1032.0",
"@aws-sdk/s3-request-presigner": "^3.1027.0", "@aws-sdk/s3-request-presigner": "^3.1032.0",
"@better-auth/passkey": "^1.6.2", "@better-auth/passkey": "^1.6.5",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.5.1", "@openrouter/ai-sdk-provider": "^2.8.0",
"@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-checkbox": "1.3.3",
@@ -75,11 +50,13 @@
"@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "2.1.8", "@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-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8", "@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "2.2.6", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8", "@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6", "@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
@@ -87,11 +64,11 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.97.0", "@tanstack/react-query": "^5.99.2",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.154", "ai": "^6.0.168",
"better-auth": "1.6.2", "better-auth": "1.6.5",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -101,16 +78,15 @@
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"next": "16.2.3", "next": "16.2.4",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "8.20.0", "pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.10.0", "resend": "^6.12.0",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
@@ -122,18 +98,18 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.10", "@biomejs/biome": "2.4.12",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.2", "@types/node": "25.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.4.1", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.3.1", "knip": "^6.4.1",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.2",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2" "typescript": "6.0.3"
} }
} }

2060
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 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: 140 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 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: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 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 },
});

View File

@@ -9,6 +9,9 @@ set -e
LOG_FILE="/tmp/openmonetis-install.log" LOG_FILE="/tmp/openmonetis-install.log"
> "$LOG_FILE" > "$LOG_FILE"
# Suprimir prompt interativo do corepack ao chamar pnpm/node versioning
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# ── Cores ────────────────────────────────────────────────────────────────────── # ── Cores ──────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
@@ -51,8 +54,8 @@ spinner_start() {
spinner_stop() { spinner_stop() {
if [ -n "$_spin_pid" ]; then if [ -n "$_spin_pid" ]; then
kill "$_spin_pid" 2>/dev/null kill "$_spin_pid" 2>/dev/null || true
wait "$_spin_pid" 2>/dev/null wait "$_spin_pid" 2>/dev/null || true
_spin_pid="" _spin_pid=""
printf "\r\033[2K" printf "\r\033[2K"
fi fi
@@ -220,15 +223,19 @@ if command -v pnpm > /dev/null 2>&1; then
else else
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando pnpm via corepack" \ run_as_user "Instalando pnpm via corepack" \
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && corepack prepare pnpm@latest --activate' 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
else else
run_quiet "Instalando pnpm via corepack" \ run_quiet "Instalando pnpm via corepack" \
sh -c 'corepack enable && corepack prepare pnpm@latest --activate' sh -c 'corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
fi fi
ok "pnpm instalado" ok "pnpm instalado"
fi fi
# ── Resumo ───────────────────────────────────────────────────────────────────── # ── 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" printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
ok "git: $(git --version | cut -d' ' -f3)" ok "git: $(git --version | cut -d' ' -f3)"
@@ -236,6 +243,3 @@ ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
ok "docker compose: $(docker compose version | cut -d' ' -f4)" ok "docker compose: $(docker compose version | cut -d' ' -f4)"
ok "node: $(node --version)" ok "node: $(node --version)"
ok "pnpm: $(pnpm --version)" ok "pnpm: $(pnpm --version)"
printf "\n${CYAN}Próximo passo:${RESET}\n"
printf " node setup.mjs\n\n"

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server"; import { getUserSession } from "@/shared/lib/auth/server";
import { isLogoDevEnabled } from "@/shared/lib/logo/server";
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@@ -13,8 +14,10 @@ export default async function DashboardLayout({
await connection(); await connection();
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id); const navbarData = await fetchDashboardNavbarData(session.user.id);
const logoDevEnabled = isLogoDevEnabled();
return ( return (
<LogoDevProvider enabled={logoDevEnabled}>
<PrivacyProvider> <PrivacyProvider>
<AppNavbar <AppNavbar
user={{ ...session.user, image: session.user.image ?? null }} user={{ ...session.user, image: session.user.image ?? null }}
@@ -23,17 +26,6 @@ export default async function DashboardLayout({
notificationsSnapshot={navbarData.notificationsSnapshot} notificationsSnapshot={navbarData.notificationsSnapshot}
/> />
<div className="relative flex flex-1 flex-col pt-16"> <div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-x-0 top-0 h-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="@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 "> <div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
{children} {children}
@@ -41,5 +33,6 @@ export default async function DashboardLayout({
</div> </div>
</div> </div>
</PrivacyProvider> </PrivacyProvider>
</LogoDevProvider>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,6 @@ import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell"
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
@@ -57,7 +56,7 @@ export default async function Page() {
<a <a
key={href} key={href}
href={href} href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors" className="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} {label}
</a> </a>
@@ -70,9 +69,9 @@ export default async function Page() {
(session?.user ? ( (session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block"> <Link prefetch href="/dashboard" className="hidden md:block">
<Button <Button
variant="outline" variant="navbar"
size="sm" size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none" className="border border-black/20 dark:border-white/20"
> >
Dashboard Dashboard
</Button> </Button>
@@ -83,7 +82,7 @@ export default async function Page() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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 Entrar
</Button> </Button>
@@ -91,7 +90,7 @@ export default async function Page() {
<Link href="/signup"> <Link href="/signup">
<Button <Button
size="sm" 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 Começar
</Button> </Button>
@@ -107,18 +106,6 @@ export default async function Page() {
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0"> <section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
<DotPattern
width={20}
height={20}
cx={1.25}
cy={1.25}
cr={1.25}
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
/>
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
</div>
<div className="max-w-8xl mx-auto px-4 relative"> <div className="max-w-8xl mx-auto px-4 relative">
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14"> <div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
<Badge variant="outline"> <Badge variant="outline">
@@ -265,72 +252,34 @@ export default async function Page() {
</AnimateOnScroll> </AnimateOnScroll>
<AnimateOnScroll> <AnimateOnScroll>
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mainFeatures.map((feature) => ( {[...mainFeatures, ...extraFeatures].map((feature) => (
<Card key={feature.title}> <Card key={feature.title}>
<CardContent className="pt-5 pb-5 md:pt-6"> <CardContent>
<div className="flex flex-col gap-3 md:gap-4"> <div className="flex items-center gap-3 mb-3">
<div <div
className="flex h-11 w-11 md:h-12 md:w-12 items-center justify-center rounded-lg" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${feature.colorVar} 20%, transparent)`,
}} }}
> >
<feature.icon <feature.icon
className="size-[22px] md:size-6" className="size-5"
style={{ color: feature.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <h3 className="font-semibold text-base leading-tight">
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
{feature.title} {feature.title}
</h3> </h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
{feature.description} {feature.description}
</p> </p>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
</AnimateOnScroll> </AnimateOnScroll>
<AnimateOnScroll>
<div className="mt-8 md:mt-12">
<h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
Também inclui
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{extraFeatures.map((feature) => (
<div
key={feature.title}
className="flex items-start gap-3 rounded-lg border bg-card p-3 md:p-4"
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
style={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
}}
>
<feature.icon
className="size-[18px]"
style={{ color: feature.colorVar }}
/>
</div>
<div className="min-w-0">
<h4 className="font-semibold text-sm mb-0.5">
{feature.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -396,14 +345,14 @@ export default async function Page() {
{pwaHighlights.map((item) => ( {pwaHighlights.map((item) => (
<li key={item.title} className="flex items-start gap-3"> <li key={item.title} className="flex items-start gap-3">
<div <div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-[15px]" className="size-[15px]"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<p className="text-sm"> <p className="text-sm">
@@ -438,17 +387,19 @@ export default async function Page() {
pré-lançamentos automaticamente para você revisar na inbox. pré-lançamentos automaticamente para você revisar na inbox.
</p> </p>
<ol className="space-y-3 mb-6"> <ol className="space-y-3 mb-6">
{companionSteps.map((step, index) => ( {companionSteps.map((step) => (
<li key={step.title} className="flex items-start gap-3"> <li key={step.title} className="flex items-start gap-3">
<span <div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${step.colorVar} 20%, transparent)`,
color: step.colorVar,
}} }}
> >
{index + 1} <step.icon
</span> className="size-3.5"
style={{ color: "var(--foreground)" }}
/>
</div>
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">{step.title}</span> <span className="font-medium">{step.title}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -545,14 +496,14 @@ export default async function Page() {
<CardContent> <CardContent>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg" className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-6" className="size-6"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <div>
@@ -633,14 +584,14 @@ export default async function Page() {
<CardContent> <CardContent>
<div className="flex gap-3 md:gap-4"> <div className="flex gap-3 md:gap-4">
<div <div
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-lg" className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-[18px] md:size-5" className="size-[18px] md:size-5"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <div>

View File

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

View File

@@ -32,7 +32,9 @@ export const user = pgTable("user", {
}).notNull(), }).notNull(),
}); });
export const account = pgTable("account", { export const account = pgTable(
"account",
{
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text("accountId").notNull(), accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(), providerId: text("providerId").notNull(),
@@ -60,9 +62,15 @@ export const account = pgTable("account", {
mode: "date", mode: "date",
withTimezone: true, withTimezone: true,
}).notNull(), }).notNull(),
}); },
(table) => ({
userIdIdx: index("account_user_id_idx").on(table.userId),
}),
);
export const session = pgTable("session", { export const session = pgTable(
"session",
{
id: text("id").primaryKey(), id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt", { expiresAt: timestamp("expiresAt", {
mode: "date", mode: "date",
@@ -82,7 +90,11 @@ export const session = pgTable("session", {
userId: text("userId") userId: text("userId")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}); },
(table) => ({
userIdIdx: index("session_user_id_idx").on(table.userId),
}),
);
export const verification = pgTable("verification", { export const verification = pgTable("verification", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -104,7 +116,9 @@ export const verification = pgTable("verification", {
// ===================== PASSKEY (WebAuthn) ===================== // ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", { export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name"), name: text("name"),
publicKey: text("publicKey").notNull(), publicKey: text("publicKey").notNull(),
@@ -121,7 +135,11 @@ export const passkey = pgTable("passkey", {
mode: "date", mode: "date",
withTimezone: true, withTimezone: true,
}), }),
}); },
(table) => ({
userIdIdx: index("passkey_user_id_idx").on(table.userId),
}),
);
export const userPreferences = pgTable("preferencias_usuario", { export const userPreferences = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
@@ -157,9 +175,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
// ===================== PUBLIC TABLES ===================== // ===================== PUBLIC TABLES =====================
export const financialAccounts = pgTable( export const financialAccounts = pgTable("contas", {
"contas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(), name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(), accountType: text("tipo_conta").notNull(),
@@ -182,14 +198,7 @@ export const financialAccounts = pgTable(
}) })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}, });
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const categories = pgTable( export const categories = pgTable(
"categorias", "categorias",
@@ -248,14 +257,6 @@ export const payers = pgTable(
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on( uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
table.shareCode, table.shareCode,
), ),
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
table.userId,
table.status,
),
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
table.userId,
table.role,
),
}), }),
); );
@@ -285,6 +286,12 @@ export const payerShares = pgTable(
table.payerId, table.payerId,
table.sharedWithUserId, table.sharedWithUserId,
), ),
sharedWithUserIdIdx: index(
"compartilhamentos_pagador_shared_with_user_id_idx",
).on(table.sharedWithUserId),
createdByUserIdIdx: index(
"compartilhamentos_pagador_created_by_user_id_idx",
).on(table.createdByUserId),
}), }),
); );
@@ -317,10 +324,7 @@ export const cards = pgTable(
}), }),
}, },
(table) => ({ (table) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on( accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
table.userId,
table.status,
),
}), }),
); );
@@ -387,10 +391,13 @@ export const budgets = pgTable(
userIdCategoryIdPeriodUnique: uniqueIndex( userIdCategoryIdPeriodUnique: uniqueIndex(
"orcamentos_user_id_categoria_id_periodo_key", "orcamentos_user_id_categoria_id_periodo_key",
).on(table.userId, table.categoryId, table.period), ).on(table.userId, table.categoryId, table.period),
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
}), }),
); );
export const notes = pgTable("anotacoes", { export const notes = pgTable(
"anotacoes",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("titulo"), title: text("titulo"),
description: text("descricao"), description: text("descricao"),
@@ -406,7 +413,11 @@ export const notes = pgTable("anotacoes", {
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}); },
(table) => ({
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
}),
);
export const savedInsights = pgTable( export const savedInsights = pgTable(
"insights_salvos", "insights_salvos",
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash), tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
}), }),
); );
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
table.userId, table.userId,
table.createdAt, table.createdAt,
), ),
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
table.transactionId,
),
}), }),
); );
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
userIdNotificationKeyUnique: uniqueIndex( userIdNotificationKeyUnique: uniqueIndex(
"dashboard_notification_states_user_id_key_unique", "dashboard_notification_states_user_id_key_unique",
).on(table.userId, table.notificationKey), ).on(table.userId, table.notificationKey),
userIdArchivedAtIdx: index(
"dashboard_notification_states_user_id_archived_idx",
).on(table.userId, table.archivedAt),
}), }),
); );
@@ -597,10 +607,14 @@ export const installmentAnticipations = pgTable(
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
table.seriesId,
),
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId), userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
transactionIdIdx: index("antecipacoes_parcelas_lancamento_id_idx").on(
table.transactionId,
),
payerIdIdx: index("antecipacoes_parcelas_pagador_id_idx").on(table.payerId),
categoryIdIdx: index("antecipacoes_parcelas_categoria_id_idx").on(
table.categoryId,
),
}), }),
); );
@@ -700,6 +714,12 @@ export const transactions = pgTable(
table.cardId, table.cardId,
table.period, table.period,
), ),
// FK indexes: evitam seq scan em deletes/updates nas tabelas pai
accountIdIdx: index("lancamentos_conta_id_idx").on(table.accountId),
categoryIdIdx: index("lancamentos_categoria_id_idx").on(table.categoryId),
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
table.anticipationId,
),
// Dedup OFX: garante FITID único por usuário // Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx") ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId) .on(table.userId, table.ofxFitId)
@@ -721,6 +741,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
installmentAnticipations: many(installmentAnticipations), installmentAnticipations: many(installmentAnticipations),
apiTokens: many(apiTokens), apiTokens: many(apiTokens),
inboxItems: many(inboxItems), inboxItems: many(inboxItems),
establishmentLogos: many(establishmentLogos),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
@@ -904,7 +925,9 @@ export const installmentAnticipationsRelations = relations(
// ===================== ATTACHMENTS ===================== // ===================== ATTACHMENTS =====================
export const attachments = pgTable("anexos", { export const attachments = pgTable(
"anexos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
@@ -916,7 +939,11 @@ export const attachments = pgTable("anexos", {
createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}); },
(table) => ({
userIdIdx: index("anexos_user_id_idx").on(table.userId),
}),
);
export const transactionAttachments = pgTable( export const transactionAttachments = pgTable(
"lancamento_anexos", "lancamento_anexos",
@@ -952,9 +979,31 @@ export const importCategoryMappings = pgTable(
}, },
(table) => ({ (table) => ({
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }), pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
table.categoryId,
),
}), }),
); );
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 User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert; export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect; export type Account = typeof account.$inferSelect;
@@ -1004,3 +1053,13 @@ export const transactionAttachmentsRelations = relations(
export type Attachment = typeof attachments.$inferSelect; export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$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) { if (hasInitialBalance && !adminPayerId) {
throw new Error( throw new Error(
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.", "Pessoa com papel administrador não encontrado. Crie um pessoa admin antes de definir um saldo inicial.",
); );
} }
@@ -299,7 +299,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) { if (!adminPayerId) {
throw new Error( throw new Error(
"Pagador administrador não encontrado. Por favor, crie um pagador admin.", "Pessoa administrador não encontrado. Por favor, crie um pessoa admin.",
); );
} }

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { import {
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiDeleteBin5Line, RiDeleteBin5Line,
@@ -47,6 +48,13 @@ export function AccountCard({
}: AccountCardProps) { }: AccountCardProps) {
const isInactive = status?.toLowerCase() === "inativa"; const isInactive = status?.toLowerCase() === "inativa";
const balanceColor =
balance > 0
? "text-success"
: balance < 0
? "text-destructive"
: "text-foreground";
const actions = [ const actions = [
{ {
label: "editar", label: "editar",
@@ -75,36 +83,39 @@ export function AccountCard({
].filter((action) => typeof action.onClick === "function"); ].filter((action) => typeof action.onClick === "function");
return ( return (
<Card className={cn("h-full w-full gap-0", className)}> <Card className={cn("flex w-full flex-col p-6", className)}>
<CardContent className="flex flex-1 flex-col gap-4"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{icon ? (
<div <div
className={cn( className={cn(
"flex items-center justify-center", "flex shrink-0 items-center justify-center",
isInactive && "[&_img]:grayscale [&_img]:opacity-40", isInactive && "grayscale opacity-40",
)} )}
> >
{icon} {icon}
</div> </div>
) : null} <div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground"> <div className="flex items-center gap-1">
<h3 className="truncate font-semibold text-foreground">
{accountName} {accountName}
</h2> </h3>
{excludeFromBalance || excludeInitialBalanceFromIncome ? (
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <button
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" /> type="button"
</div> 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> </TooltipTrigger>
<TooltipContent side="right" className="max-w-xs"> <TooltipContent side="top" align="start" className="max-w-xs">
<div className="space-y-1"> <div className="space-y-1">
{excludeFromBalance && ( {excludeFromBalance && (
<p className="text-xs"> <p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta <strong>Desconsiderado do saldo total:</strong> Esta
não é incluída no cálculo do saldo total geral. conta não é incluída no cálculo do saldo total geral.
</p> </p>
)} )}
{excludeInitialBalanceFromIncome && ( {excludeInitialBalanceFromIncome && (
@@ -119,17 +130,27 @@ export function AccountCard({
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} ) : null}
</div> </div>
<div className="space-y-2"> <p className="text-xs text-muted-foreground">{status}</p>
<MoneyValues amount={balance} className="text-3xl" /> </div>
<p className="text-sm text-muted-foreground">{accountType}</p> </div>
<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> </div>
</CardContent> </CardContent>
{actions.length > 0 ? ( <CardFooter className="flex flex-wrap gap-4 p-0 text-sm">
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
{actions.map(({ label, icon, onClick, variant }) => ( {actions.map(({ label, icon, onClick, variant }) => (
<button <button
key={label} key={label}
@@ -146,7 +167,6 @@ export function AccountCard({
</button> </button>
))} ))}
</CardFooter> </CardFooter>
) : null}
</Card> </Card>
); );
} }

View File

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

View File

@@ -86,7 +86,7 @@ export function AccountStatementCard({
</p> </p>
<MoneyValues <MoneyValues
amount={currentBalance} amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-[2rem]" className="text-3xl leading-none tracking-tighter sm:text-2xl"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
@@ -123,7 +123,7 @@ export function AccountStatementCard({
<MetaItem <MetaItem
label="Saídas" label="Saídas"
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)." tooltip="Total de despesas pagas neste mês (considerando divisão entre pessoas)."
> >
<span className="text-sm font-medium text-destructive"> <span className="text-sm font-medium text-destructive">
{formatCurrency(totalExpenses)} {formatCurrency(totalExpenses)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = const description =
mode === "create" mode === "create"
? "Inclua um novo cartão de crédito para acompanhar seus gastos." ? "Inclua um novo cartão de crédito para acompanhar seus gastos."
: "Atualize as informações do cartão selecionado."; : "Atualize as informações do cartão selecionado.";
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão"; const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
const handleMainDialogOpenChange = (open: boolean) => { const handleMainDialogOpenChange = (open: boolean) => {
if (!open && logoDialogOpen) { if (!open && logoDialogOpen) {

View File

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

View File

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

View File

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

View File

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

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 { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants"; import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency"; import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
type CategorySummary = { type CategorySummary = {
id: string; id: string;
@@ -33,33 +32,6 @@ export function CategoryDetailHeader({
percentageChange, percentageChange,
transactionCount, transactionCount,
}: CategoryDetailHeaderProps) { }: 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 = const variationLabel =
typeof percentageChange === "number" typeof percentageChange === "number"
? formatPercentage(percentageChange, { ? formatPercentage(percentageChange, {
@@ -115,15 +87,13 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior Variação vs mês anterior
</p> </p>
<div <PercentageChangeIndicator
className={cn( value={percentageChange}
"mt-1 flex items-center gap-1 text-lg font-semibold", label={variationLabel}
variationColor, positiveTrend={category.type === "receita" ? "up" : "down"}
)} className="mt-1 gap-1 text-lg font-semibold"
> iconClassName="size-4"
{variationIcon} />
<span>{variationLabel}</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -136,13 +136,12 @@ export function CategoryDialog({
}); });
}; };
const title = mode === "create" ? "Nova categoria" : "Editar categoria"; const title = mode === "create" ? "Nova categoria" : "Atualizar categoria";
const description = const description =
mode === "create" mode === "create"
? "Crie uma categoria para organizar seus lançamentos." ? "Crie uma categoria para organizar seus lançamentos."
: "Atualize os detalhes da categoria selecionada."; : "Atualize os detalhes da categoria selecionada.";
const submitLabel = const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
mode === "create" ? "Salvar categoria" : "Atualizar categoria";
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>

View File

@@ -1,6 +0,0 @@
// Re-export from shared — componente movido para src/shared/components/entity-avatar/
export {
CategoryIconBadge,
type CategoryIconBadgeProps,
type CategoryIconBadgeSize,
} from "@/shared/components/entity-avatar";

View File

@@ -1,11 +1,5 @@
import type { CategoryType } from "@/shared/lib/categories/constants"; import type { CategoryType } from "@/shared/lib/categories/constants";
export type { CategoryType } from "@/shared/lib/categories/constants";
export {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/shared/lib/categories/constants";
export type Category = { export type Category = {
id: string; id: string;
name: string; name: string;

View File

@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type Category, categories } from "@/db/schema"; import { type Category, categories } from "@/db/schema";
import type { CategoryType } from "@/features/categories/components/types"; import type { CategoryType } from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
export type CategoryData = { export type CategoryData = {

View File

@@ -26,7 +26,7 @@ export type DashboardAccount = {
excludeFromBalance: boolean; excludeFromBalance: boolean;
}; };
export type DashboardAccountsSnapshot = { type DashboardAccountsSnapshot = {
totalBalance: number; totalBalance: number;
accounts: DashboardAccount[]; accounts: DashboardAccount[];
}; };

View File

@@ -1,166 +0,0 @@
"use server";
import { and, eq } from "drizzle-orm";
import { transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
compareDateOnly,
getBusinessDateString,
isDateOnlyPast,
toDateOnlyString,
} from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
type RawDashboardBill = {
id: string;
name: string;
amount: string | number | null;
dueDate: string | Date | null;
boletoPaymentDate: string | Date | null;
isSettled: boolean | null;
};
export type DashboardBill = {
id: string;
name: string;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean;
};
export type DashboardBillsSnapshot = {
bills: DashboardBill[];
totalPendingAmount: number;
pendingCount: number;
};
const compareDateOnlyAscWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(left, right);
};
const compareDateOnlyDescWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(right, left);
};
export async function fetchDashboardBills(
userId: string,
period: string,
): Promise<DashboardBillsSnapshot> {
const today = getBusinessDateString();
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
}
const rows = await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
boletoPaymentDate: transactions.boletoPaymentDate,
isSettled: transactions.isSettled,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(transactions.payerId, adminPayerId),
),
);
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
const amount = Math.abs(toNumber(row.amount));
return {
id: row.id,
name: row.name,
amount,
dueDate: toDateOnlyString(row.dueDate),
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
};
});
bills.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
return a.isSettled ? 1 : -1;
}
if (!a.isSettled && !b.isSettled) {
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
if (aIsOverdue !== bIsOverdue) {
return aIsOverdue ? -1 : 1;
}
const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate);
if (dueDateDiff !== 0) {
return dueDateDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
if (a.isSettled && b.isSettled) {
const paidAtDiff = compareDateOnlyDescWithNullsLast(
a.boletoPaymentDate,
b.boletoPaymentDate,
);
if (paidAtDiff !== 0) {
return paidAtDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
const nameDiff = a.name.localeCompare(b.name, "pt-BR", {
sensitivity: "base",
});
if (nameDiff !== 0) {
return nameDiff;
}
return a.id.localeCompare(b.id);
});
let totalPendingAmount = 0;
let pendingCount = 0;
for (const bill of bills) {
if (!bill.isSettled) {
totalPendingAmount += bill.amount;
pendingCount += 1;
}
}
return {
bills,
totalPendingAmount,
pendingCount,
};
}

View File

@@ -1,5 +1,5 @@
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller"; import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date"; import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
import { import {
buildFinancialStatusLabel, buildFinancialStatusLabel,

View File

@@ -0,0 +1,14 @@
export type DashboardBill = {
id: string;
name: string;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean;
};
export type DashboardBillsSnapshot = {
bills: DashboardBill[];
totalPendingAmount: number;
pendingCount: number;
};

View File

@@ -4,17 +4,17 @@ import {
type BillDialogState, type BillDialogState,
getCurrentBillDateString, getCurrentBillDateString,
markBillAsSettled, markBillAsSettled,
} from "@/features/dashboard/bills-helpers"; } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { import {
type PaymentDialogController, type PaymentDialogController,
usePaymentDialogController, usePaymentDialogController,
} from "@/features/dashboard/use-payment-dialog-controller"; } from "@/features/dashboard/payments/use-payment-dialog-controller";
import { toggleTransactionSettlementAction } from "@/features/transactions/actions"; import { toggleTransactionSettlementAction } from "@/features/transactions/actions";
const EMPTY_BILLS: DashboardBill[] = []; const EMPTY_BILLS: DashboardBill[] = [];
export type BillWidgetController = Omit< type BillWidgetController = Omit<
PaymentDialogController<DashboardBill>, PaymentDialogController<DashboardBill>,
"selectedItem" "selectedItem"
> & { > & {

View File

@@ -51,7 +51,7 @@ type UniqueCategory = {
icon: string | null; icon: string | null;
}; };
export async function fetchAllCategories( async function fetchAllCategories(
userId: string, userId: string,
): Promise<CategoryOption[]> { ): Promise<CategoryOption[]> {
const result = await db const result = await db

View File

@@ -8,14 +8,14 @@ import {
import { import {
buildCategoryBreakdownData, buildCategoryBreakdownData,
type DashboardCategoryBreakdownData, type DashboardCategoryBreakdownData,
} from "@/features/dashboard/categories/category-breakdown"; } from "@/features/dashboard/categories/category-breakdown-helpers";
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries"; import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries"; import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
import type { import type {
GoalProgressCategory, GoalProgressCategory,
GoalProgressItem, GoalProgressItem,
GoalsProgressData, GoalsProgressData,
} from "@/features/dashboard/goals-progress-queries"; } from "@/features/dashboard/goals-progress/goals-progress-queries";
import { import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
@@ -50,7 +50,7 @@ type BudgetSnapshotRow = {
amount: string | number | null; amount: string | number | null;
}; };
export type DashboardCategoryOverview = { type DashboardCategoryOverview = {
goalsProgressData: GoalsProgressData; goalsProgressData: GoalsProgressData;
incomeByCategoryData: IncomeByCategoryData; incomeByCategoryData: IncomeByCategoryData;
expensesByCategoryData: ExpensesByCategoryData; expensesByCategoryData: ExpensesByCategoryData;

View File

@@ -1,82 +1,3 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/features/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
export type ExpensesByCategoryData = DashboardCategoryBreakdownData; export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
export async function fetchExpensesByCategory(
userId: string,
period: string,
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoryId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -1,84 +1,3 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/features/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
export type IncomeByCategoryData = DashboardCategoryBreakdownData; export type IncomeByCategoryData = DashboardCategoryBreakdownData;
export async function fetchIncomeByCategory(
userId: string,
period: string,
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoryId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -0,0 +1,18 @@
export type CategoryOption = {
id: string;
name: string;
type: string;
};
export type CategoryTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
logo: string | null;
};
export type PurchasesByCategoryData = {
categories: CategoryOption[];
transactionsByCategory: Record<string, CategoryTransaction[]>;
};

View File

@@ -3,8 +3,8 @@ import {
buildBillStatusLabel, buildBillStatusLabel,
buildBillWidgetStatusLabel, buildBillWidgetStatusLabel,
isBillOverdue, isBillOverdue,
} from "@/features/dashboard/bills-helpers"; } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
@@ -82,8 +82,8 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
onClick={() => onPay(bill.id)} onClick={() => onPay(bill.id)}
> >
{bill.isSettled ? ( {bill.isSettled ? (
<span className="flex items-center gap-1 text-success"> <span className="flex items-center gap-0.5 text-success">
<RiCheckboxCircleFill className="size-4" /> Pago <RiCheckboxCircleFill className="size-3.5" /> Pago
</span> </span>
) : overdue ? ( ) : overdue ? (
<span className="overdue-blink"> <span className="overdue-blink">

View File

@@ -8,8 +8,8 @@ import {
type BillDialogState, type BillDialogState,
formatBillDateLabel, formatBillDateLabel,
getBillStatusBadgeVariant, getBillStatusBadgeVariant,
} from "@/features/dashboard/bills-helpers"; } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { PaymentSuccess } from "@/shared/components/payment-success"; import { PaymentSuccess } from "@/shared/components/payment-success";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
@@ -171,7 +171,7 @@ export function BillPaymentDialog({
Processando... Processando...
</> </>
) : ( ) : (
"Confirmar pagamento" "Confirmar"
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -1,5 +1,5 @@
import { RiBarcodeFill } from "@remixicon/react"; import { RiBarcodeFill } from "@remixicon/react";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { BillListItem } from "./bill-list-item"; import { BillListItem } from "./bill-list-item";

View File

@@ -1,5 +1,5 @@
import type { BillDialogState } from "@/features/dashboard/bills-helpers"; import type { BillDialogState } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { BillPaymentDialog } from "./bill-payment-dialog"; import { BillPaymentDialog } from "./bill-payment-dialog";
import { BillsList } from "./bills-list"; import { BillsList } from "./bills-list";

View File

@@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
const CATEGORY_BREAKDOWN_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
type CategoryBreakdownChartProps = {
categories: DashboardCategoryBreakdownItem[];
percentageDigits: number;
};
export function CategoryBreakdownChart({
categories,
percentageDigits,
}: CategoryBreakdownChartProps) {
const chartConfig = useMemo(() => {
const nextConfig: ChartConfig = {};
const topCategories = categories.slice(0, 7);
topCategories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
if (categories.length > 7) {
nextConfig.outros = { label: "Outros", color: "var(--chart-6)" };
}
return nextConfig;
}, [categories]);
const chartData = useMemo(() => {
if (categories.length <= 7) {
return categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
const topCategories = categories.slice(0, 7);
const otherCategories = categories.slice(7);
const otherTotal = otherCategories.reduce(
(sum, c) => sum + c.currentAmount,
0,
);
const otherPercentage = otherCategories.reduce(
(sum, c) => sum + c.percentageOfTotal,
0,
);
const groupedData = topCategories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
if (otherCategories.length > 0) {
groupedData.push({
category: "outros",
name: "Outros",
value: otherTotal,
percentage: otherPercentage,
fill: chartConfig.outros?.color,
});
}
return groupedData;
}, [categories, chartConfig]);
return (
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ payload }) =>
formatPercentage(
(payload as { percentage?: number } | undefined)?.percentage ??
0,
percentageDigits,
)
}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const entry = payload[0]?.payload;
if (!entry) return null;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-xs uppercase text-muted-foreground">
{entry.name}
</span>
<span className="font-medium text-foreground">
{formatCurrency(entry.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(entry.percentage, percentageDigits)}{" "}
do total
</span>
</div>
</div>
</div>
);
}}
/>
</PieChart>
</ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.fill }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react";
import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
type CategoryBreakdownListItemConfig = {
shareLabel: string;
percentageDigits: number;
positiveTrend: "up" | "down";
includeBudgetAmount: boolean;
};
type CategoryBreakdownListItemProps = {
category: DashboardCategoryBreakdownItem;
periodParam: string;
config: CategoryBreakdownListItemConfig;
};
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
export function CategoryBreakdownListItem({
category,
periodParam,
config,
}: CategoryBreakdownListItemProps) {
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>
{formatPercentage(
category.percentageOfTotal,
config.percentageDigits,
)}{" "}
da {config.shareLabel}
</span>
{hasBudget && category.budgetUsedPercentage !== null ? (
<>
<span aria-hidden>·</span>
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
>
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? (
<>
excedeu{" "}
<span className="font-medium">
{formatCurrency(exceededAmount)}
</span>
</>
) : (
<>
{formatPercentage(
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</>
)}
</span>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground font-medium"
amount={category.currentAmount}
/>
<PercentageChangeIndicator
value={category.percentageChange}
label={
category.percentageChange !== null
? formatPercentage(
category.percentageChange,
config.percentageDigits,
)
: undefined
}
positiveTrend={config.positiveTrend}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { CategoryBreakdownListItem } from "./category-breakdown-list-item";
type CategoryBreakdownListConfig = {
shareLabel: string;
percentageDigits: number;
positiveTrend: "up" | "down";
includeBudgetAmount: boolean;
};
type CategoryBreakdownListProps = {
categories: DashboardCategoryBreakdownItem[];
periodParam: string;
config: CategoryBreakdownListConfig;
};
export function CategoryBreakdownList({
categories,
periodParam,
config,
}: CategoryBreakdownListProps) {
return (
<div>
{categories.map((category) => (
<CategoryBreakdownListItem
key={category.categoryId}
category={category}
periodParam={periodParam}
config={config}
/>
))}
</div>
);
}

View File

@@ -1,21 +1,12 @@
"use client"; "use client";
import { import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered, RiListUnordered,
RiPieChart2Line, RiPieChart2Line,
RiPieChartLine, RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import { useState } from "react";
import { useMemo, useState } from "react"; import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
import { Pie, PieChart, Tooltip } from "recharts";
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -23,9 +14,9 @@ import {
TabsTrigger, TabsTrigger,
} from "@/shared/components/ui/tabs"; } from "@/shared/components/ui/tabs";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period"; import { formatPeriodForUrl } from "@/shared/utils/period";
import { CategoryBreakdownChart } from "./category-breakdown-chart";
import { CategoryBreakdownList } from "./category-breakdown-list";
type CategoryBreakdownVariant = "income" | "expense"; type CategoryBreakdownVariant = "income" | "expense";
@@ -35,16 +26,6 @@ type CategoryBreakdownWidgetViewProps = {
variant: CategoryBreakdownVariant; variant: CategoryBreakdownVariant;
}; };
const CATEGORY_BREAKDOWN_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
const VARIANT_CONFIG = { const VARIANT_CONFIG = {
income: { income: {
emptyTitle: "Nenhuma receita encontrada", emptyTitle: "Nenhuma receita encontrada",
@@ -52,10 +33,7 @@ const VARIANT_CONFIG = {
"Quando houver receitas registradas, elas aparecerão aqui.", "Quando houver receitas registradas, elas aparecerão aqui.",
shareLabel: "receita total", shareLabel: "receita total",
percentageDigits: 1, percentageDigits: 1,
changeClassName: { positiveTrend: "up",
increase: "text-success",
decrease: "text-destructive",
},
includeBudgetAmount: true, includeBudgetAmount: true,
}, },
expense: { expense: {
@@ -64,21 +42,11 @@ const VARIANT_CONFIG = {
"Quando houver despesas registradas, elas aparecerão aqui.", "Quando houver despesas registradas, elas aparecerão aqui.",
shareLabel: "despesa total", shareLabel: "despesa total",
percentageDigits: 0, percentageDigits: 0,
changeClassName: { positiveTrend: "down",
increase: "text-destructive",
decrease: "text-success",
},
includeBudgetAmount: false, includeBudgetAmount: false,
}, },
} as const; } as const;
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
export function CategoryBreakdownWidgetView({ export function CategoryBreakdownWidgetView({
data, data,
period, period,
@@ -88,78 +56,6 @@ export function CategoryBreakdownWidgetView({
const periodParam = formatPeriodForUrl(period); const periodParam = formatPeriodForUrl(period);
const config = VARIANT_CONFIG[variant]; const config = VARIANT_CONFIG[variant];
const chartConfig = useMemo(() => {
const nextConfig: ChartConfig = {};
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
} else {
const topCategories = data.categories.slice(0, 7);
topCategories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
nextConfig.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return nextConfig;
}, [data.categories]);
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
const topCategories = data.categories.slice(0, 7);
const otherCategories = data.categories.slice(7);
const otherTotal = otherCategories.reduce(
(sum, category) => sum + category.currentAmount,
0,
);
const otherPercentage = otherCategories.reduce(
(sum, category) => sum + category.percentageOfTotal,
0,
);
const groupedData = topCategories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
if (otherCategories.length > 0) {
groupedData.push({
category: "outros",
name: "Outros",
value: otherTotal,
percentage: otherPercentage,
fill: chartConfig.outros?.color,
});
}
return groupedData;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) { if (data.categories.length === 0) {
return ( return (
<WidgetEmptyState <WidgetEmptyState
@@ -178,11 +74,17 @@ export function CategoryBreakdownWidgetView({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2"> <TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs"> <TabsTrigger
value="list"
className="text-xs data-[state=active]:bg-transparent"
>
<RiListUnordered className="mr-1 size-3.5" /> <RiListUnordered className="mr-1 size-3.5" />
Lista Lista
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="chart" className="text-xs"> <TabsTrigger
value="chart"
className="text-xs data-[state=active]:bg-transparent"
>
<RiPieChart2Line className="mr-1 size-3.5" /> <RiPieChart2Line className="mr-1 size-3.5" />
Gráfico Gráfico
</TabsTrigger> </TabsTrigger>
@@ -190,195 +92,18 @@ export function CategoryBreakdownWidgetView({
</div> </div>
<TabsContent value="list" className="mt-0"> <TabsContent value="list" className="mt-0">
<div> <CategoryBreakdownList
{data.categories.map((category, index) => { categories={data.categories}
const hasIncrease = periodParam={periodParam}
category.percentageChange !== null && config={config}
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const changeClassName = hasIncrease
? config.changeClassName.increase
: hasDecrease
? config.changeClassName.decrease
: "text-muted-foreground";
return (
<div key={category.categoryId}>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
/> />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>
{formatPercentage(
category.percentageOfTotal,
config.percentageDigits,
)}{" "}
da {config.shareLabel}
</span>
{hasBudget && category.budgetUsedPercentage !== null ? (
<>
<span aria-hidden>·</span>
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
>
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? (
<>
excedeu{" "}
<span className="font-medium">
{formatCurrency(exceededAmount)}
</span>
</>
) : (
<>
{formatPercentage(
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</>
)}
</span>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground font-medium"
amount={category.currentAmount}
/>
{category.percentageChange !== null ? (
<span
className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
>
{hasIncrease ? (
<RiArrowUpSFill className="size-3" />
) : null}
{hasDecrease ? (
<RiArrowDownSFill className="size-3" />
) : null}
{formatPercentage(
category.percentageChange,
config.percentageDigits,
)}
</span>
) : null}
</div>
</div>
</div>
);
})}
</div>
</TabsContent> </TabsContent>
<TabsContent value="chart" className="mt-0"> <TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4"> <CategoryBreakdownChart
<ChartContainer config={chartConfig} className="h-[280px] flex-1"> categories={data.categories}
<PieChart> percentageDigits={config.percentageDigits}
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ payload }) =>
formatPercentage(
(payload as { percentage?: number } | undefined)
?.percentage ?? 0,
config.percentageDigits,
)
}
outerRadius={75}
dataKey="value"
nameKey="category"
/> />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) {
return null;
}
const entry = payload[0]?.payload;
if (!entry) {
return null;
}
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-xs uppercase text-muted-foreground">
{entry.name}
</span>
<span className="font-medium text-foreground">
{formatCurrency(entry.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(
entry.percentage,
config.percentageDigits,
)}{" "}
do total
</span>
</div>
</div>
</div>
);
}}
/>
</PieChart>
</ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.fill }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
); );

View File

@@ -25,19 +25,19 @@ import {
} from "@remixicon/react"; } from "@remixicon/react";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { SortableWidget } from "@/features/dashboard/components/sortable-widget"; import { SortableWidget } from "@/features/dashboard/components/widgets/sortable-widget";
import { WidgetSettingsDialog } from "@/features/dashboard/components/widget-settings-dialog"; import { WidgetSettingsDialog } from "@/features/dashboard/components/widgets/widget-settings-dialog";
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data"; import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
import { import {
resetWidgetPreferences, resetWidgetPreferences,
updateWidgetPreferences, updateWidgetPreferences,
type WidgetPreferences, type WidgetPreferences,
} from "@/features/dashboard/widgets/actions"; } from "@/features/dashboard/widget-registry/widget-actions";
import { import {
type DashboardWidgetQuickActionOptions, type DashboardWidgetQuickActionOptions,
type WidgetConfig, type WidgetConfig,
widgetsConfig, widgetsConfig,
} from "@/features/dashboard/widgets/widgets-config"; } from "@/features/dashboard/widget-registry/widget-config";
import { NoteDialog } from "@/features/notes/components/note-dialog"; import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog"; import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";

View File

@@ -1,14 +1,12 @@
import { import {
RiArrowDownLine, RiArrowLeftRightLine,
RiArrowDownSFill, RiArrowRightDownLine,
RiArrowUpLine, RiArrowRightUpLine,
RiArrowUpSFill, RiCalendar2Line,
RiCalendarCheckLine,
RiScalesLine,
RiSubtractLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button"; import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { import {
Card, Card,
@@ -34,13 +32,13 @@ const CARDS = [
label: "Receitas", label: "Receitas",
subtitle: "Entradas do período", subtitle: "Entradas do período",
key: "receitas", key: "receitas",
icon: RiArrowDownLine, icon: RiArrowRightDownLine,
invertTrend: false, invertTrend: false,
iconClass: "text-success", iconClass: "text-success",
helpTitle: "Como calculamos receitas", helpTitle: "Como calculamos receitas",
helpLines: [ helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.", "Somamos os lançamentos do tipo Receita no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).", "Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.", "Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.", "Não entram transferências internas nem lançamentos automáticos de fatura.",
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.", "Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
@@ -50,13 +48,13 @@ const CARDS = [
label: "Despesas", label: "Despesas",
subtitle: "Saídas do período", subtitle: "Saídas do período",
key: "despesas", key: "despesas",
icon: RiArrowUpLine, icon: RiArrowRightUpLine,
invertTrend: true, invertTrend: true,
iconClass: "text-destructive", iconClass: "text-destructive",
helpTitle: "Como calculamos despesas", helpTitle: "Como calculamos despesas",
helpLines: [ helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.", "Somamos os lançamentos do tipo Despesa no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).", "Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.", "Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.", "Não entram transferências internas nem lançamentos automáticos de fatura.",
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.", "O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
@@ -66,7 +64,7 @@ const CARDS = [
label: "Balanço", label: "Balanço",
subtitle: "Receitas, despesas e ajustes entre contas", subtitle: "Receitas, despesas e ajustes entre contas",
key: "balanco", key: "balanco",
icon: RiScalesLine, icon: RiArrowLeftRightLine,
invertTrend: false, invertTrend: false,
iconClass: "text-warning", iconClass: "text-warning",
helpTitle: "Como calculamos o balanço", helpTitle: "Como calculamos o balanço",
@@ -81,7 +79,7 @@ const CARDS = [
label: "Previsto", label: "Previsto",
subtitle: "Saldo acumulado projetado", subtitle: "Saldo acumulado projetado",
key: "previsto", key: "previsto",
icon: RiCalendarCheckLine, icon: RiCalendar2Line,
invertTrend: false, invertTrend: false,
iconClass: "text-cyan-600", iconClass: "text-cyan-600",
helpTitle: "Como calculamos o previsto", helpTitle: "Como calculamos o previsto",
@@ -94,12 +92,6 @@ const CARDS = [
}, },
] as const; ] as const;
const TREND_ICONS = {
up: RiArrowUpSFill,
down: RiArrowDownSFill,
flat: RiSubtractLine,
} as const;
const getTrend = (current: number, previous: number): Trend => { const getTrend = (current: number, previous: number): Trend => {
const diff = current - previous; const diff = current - previous;
if (diff > TREND_THRESHOLD) return "up"; if (diff > TREND_THRESHOLD) return "up";
@@ -126,12 +118,6 @@ const getPercentChange = (current: number, previous: number): string => {
}); });
}; };
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
if (trend === "flat") return "text-muted-foreground";
const isPositive = invertTrend ? trend === "down" : trend === "up";
return isPositive ? "text-success" : "text-destructive";
};
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) { export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
@@ -148,8 +134,6 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
}) => { }) => {
const metric = metrics[key]; const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous); const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
const trendBadgeClass = getTrendBadgeClass(trend, invertTrend);
const percentChange = getPercentChange( const percentChange = getPercentChange(
metric.current, metric.current,
metric.previous, metric.previous,
@@ -157,10 +141,8 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return ( return (
<Card key={label} className="gap-2 overflow-hidden"> <Card key={label} className="gap-2 overflow-hidden">
<CardHeader> <CardHeader className="gap-1">
<div className="flex items-start justify-between"> <CardTitle className="flex items-center gap-1">
<div>
<CardTitle className="flex items-center gap-1.5 ">
<Icon className={cn("size-4", iconClass)} aria-hidden /> <Icon className={cn("size-4", iconClass)} aria-hidden />
{label} {label}
<MetricsCardInfoButton <MetricsCardInfoButton
@@ -169,11 +151,9 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
helpLines={helpLines} helpLines={helpLines}
/> />
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5 tracking-tight"> <CardDescription className="mt-1 tracking-tight">
{subtitle} {subtitle}
</CardDescription> </CardDescription>
</div>
</div>
<Separator className="mt-1" /> <Separator className="mt-1" />
</CardHeader> </CardHeader>
@@ -183,15 +163,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
className="text-2xl leading-none font-medium" className="text-2xl leading-none font-medium"
amount={metric.current} amount={metric.current}
/> />
<div <PercentageChangeIndicator
className={cn( trend={trend}
"inline-flex items-center gap-1 text-xs font-medium", label={percentChange}
trendBadgeClass, positiveTrend={invertTrend ? "down" : "up"}
)} showFlatIcon
> className="gap-1"
<TrendIcon className="size-3.5" aria-hidden /> iconClassName="size-3.5"
<span>{percentChange}</span> />
</div>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">

View File

@@ -1,4 +1,4 @@
import { formatCurrentDate, getGreeting } from "./welcome-widget"; import { formatCurrentDate, getGreeting } from "@/features/dashboard/widget-registry/welcome-widget";
type DashboardWelcomeProps = { type DashboardWelcomeProps = {
name?: string | null; name?: string | null;
@@ -10,13 +10,11 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) {
const greeting = getGreeting(); const greeting = getGreeting();
return ( return (
<section className="py-4"> <section className="py-4 space-y-1">
<div>
<h1 className="text-xl tracking-tight"> <h1 className="text-xl tracking-tight">
{greeting}, {displayName} <span className="text-muted-foreground">{greeting},</span> {displayName}
</h1> </h1>
<h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2> <h2 className="text-sm text-muted-foreground">{formattedDate}</h2>
</div>
</section> </section>
); );
} }

View File

@@ -1,9 +1,10 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { import {
clampGoalProgress, clampGoalProgress,
formatGoalProgressPercentage, formatGoalProgressPercentage,
} from "@/features/dashboard/goals-progress-helpers"; } from "@/features/dashboard/goals-progress/goals-progress-helpers";
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress-queries"; import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress/goals-progress-queries";
import { CategoryIconBadge } from "@/shared/components/entity-avatar"; import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
@@ -22,12 +23,6 @@ export function GoalProgressItem({
}: GoalProgressItemProps) { }: GoalProgressItemProps) {
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100); const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100; const percentageDelta = item.usedPercentage - 100;
const deltaColor =
percentageDelta > 0
? "text-destructive"
: percentageDelta < 0
? "text-success"
: "text-muted-foreground";
const isExceeded = item.status === "exceeded"; const isExceeded = item.status === "exceeded";
return ( return (
@@ -47,9 +42,12 @@ export function GoalProgressItem({
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "} <MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
de{" "} de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} /> <MoneyValues className="font-medium" amount={item.budgetAmount} />
<span className={`ml-1.5 font-medium ${deltaColor}`}> <PercentageChangeIndicator
{formatGoalProgressPercentage(percentageDelta, true)} value={percentageDelta}
</span> label={formatGoalProgressPercentage(percentageDelta, true)}
positiveTrend="down"
className="ml-1.5 align-middle"
/>
</p> </p>
</div> </div>
</div> </div>
@@ -61,7 +59,7 @@ export function GoalProgressItem({
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="transition-opacity text-primary hover:opacity-80"
onClick={() => onEdit(item)} onClick={() => onEdit(item)}
aria-label={`Editar orçamento de ${item.categoryName}`} aria-label={`Atualizar orçamento de ${item.categoryName}`}
> >
<RiPencilLine className="size-3.5" /> <RiPencilLine className="size-3.5" />
</Button> </Button>

View File

@@ -1,7 +1,7 @@
import { RiFundsLine } from "@remixicon/react"; import { RiFundsLine } from "@remixicon/react";
import type { GoalProgressItem } from "@/features/dashboard/goals-progress-queries"; import type { GoalProgressItem } from "@/features/dashboard/goals-progress/goals-progress-queries";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item"; import { GoalProgressItem as GoalProgressListItem } from "./goals-progress-item";
type GoalsProgressListProps = { type GoalsProgressListProps = {
items: GoalProgressItem[]; items: GoalProgressItem[];

View File

@@ -5,7 +5,7 @@ import type {
import type { import type {
GoalProgressItem, GoalProgressItem,
GoalsProgressData, GoalsProgressData,
} from "@/features/dashboard/goals-progress-queries"; } from "@/features/dashboard/goals-progress/goals-progress-queries";
import { GoalsProgressList } from "./goals-progress-list"; import { GoalsProgressList } from "./goals-progress-list";
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs"; import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";

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