15 Commits

Author SHA1 Message Date
Felipe Coutinho
d01bc8a669 fix(docker): remove chown recursivo da imagem final 2026-04-01 17:15:06 +00:00
Felipe Coutinho
e024e0d54e fix(docker): cria pasta public antes do pnpm install
O postinstall do pdfjs-dist tenta copiar pdf.worker.min.mjs para
public/, mas no stage deps do Dockerfile a pasta não existia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:49:00 +00:00
Felipe Coutinho
c44089169f style: troca subpixel-antialiased por antialiased no body
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:35:16 +00:00
Felipe Coutinho
d04e30e3c9 fix(robots): remove estático duplicado, corrige robots.ts e llms.txt
Remove `public/robots.txt` que era ignorado pelo Next.js em favor do
`src/app/robots.ts` (Metadata API). Adiciona `/signup` à lista de
rotas bloqueadas e remove referência ao `sitemap.xml` inexistente.

Restaura o link `/robots.txt` no `llms.txt`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:31:37 +00:00
Felipe Coutinho
229b6c5bc0 chore: adiciona robots.txt bloqueando rotas privadas do app
Permite indexação apenas da landing page e do llms.txt. Bloqueia todas
as rotas autenticadas (dashboard, transações, contas, cartões, etc.)
e a API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:29:39 +00:00
Felipe Coutinho
c3b133d8d9 docs(llms.txt): adiciona stack técnico e remove links inexistentes
Inclui stack no cabeçalho do arquivo e remove referências a AGENTS.md,
robots.txt e sitemap.xml que não existem no repositório.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:24:04 +00:00
Felipe Coutinho
e9a2ab1782 chore(release): publicar versão 2.2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:18:55 +00:00
Felipe Coutinho
c7d6e23398 chore: atualiza biome, CLAUDE.md, llms.txt e corrige optional chaining
- biome.json: schema atualizado para 2.4.9
- public/llms.txt: novo arquivo de documentação pública do projeto
- CLAUDE.md: ajustes menores de documentação interna
- invoices-queries.ts: usa optional chaining `?.startsWith` no lugar de
  verificação dupla de nullish
- CHANGELOG.md: documentadas as mudanças do ciclo atual em [Unreleased]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:15:03 +00:00
Felipe Coutinho
0514efb1c4 style(tipografia): adiciona fonte America Medium e padroniza pesos de texto
Adiciona os arquivos `america-medium.woff2` e `america-bold.woff2` e
registra o weight 500 no `font_index.ts`.

Padroniza o uso de `font-medium` em substituição a `font-semibold` e
`font-bold` em títulos, valores monetários e rótulos de destaque em
todos os componentes do app, landing page e componentes de UI base.

`Card` ganha `hover:border-primary/40` e `CardTitle` recebe `text-base`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:55 +00:00
Felipe Coutinho
e32fb85006 perf(cache): migração para diretiva use cache do Next.js
Todas as queries cacheadas do dashboard migram de `unstable_cache` para
a diretiva `use cache` com `cacheTag` e `cacheLife({ revalidate: 3 })`.

Todas as páginas e o layout do dashboard passam a chamar `connection()`
para garantir renderização dinâmica. O root layout envolve os filhos em
`<Suspense>`. `next.config.ts` remove `turbopackFileSystemCacheForDev`
e adota `cacheComponents: true`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:23 +00:00
Felipe Coutinho
96df6a1798 feat(notificações): alertas de vencimento para o período seguinte
Boletos e faturas do próximo período com vencimento dentro de 5 dias
agora geram notificações do tipo `due_soon`, evitando duplicatas com
notificações já existentes do período corrente.

A query de boletos passa a filtrar pela data de vencimento não nula e
limita a janela de busca a 12 meses anteriores ao período atual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:07 +00:00
Felipe Coutinho
1f8a97bd16 feat(auth): redesign visual das páginas de autenticação
O sidebar de autenticação ganha mockup animado de faturas e três itens
de funcionalidade no rodapé, substituindo o texto descritivo anterior.

As páginas de login e cadastro recebem gradiente decorativo de fundo e
exibem o logo no topo em viewports mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:01 +00:00
Felipe Coutinho
0ab3298cef feat(anexos): página de galeria de comprovantes e documentos
Adiciona rota `/attachments` com visualização de todos os anexos do
usuário em grade, visualização inline de imagem e PDF, navegação entre
arquivos do mesmo lançamento e download direto.

Inclui também:
- API REST em `/api/attachments` para servir os arquivos
- Actions `fetch-by-id` e `fetch-dialog-options` em transactions
- Item "Anexos" adicionado à navbar
- `formatBytes` extraído para `src/shared/utils/number.ts`
- Migrations de banco atualizadas
- Fix: uploads e remoções de anexo agora funcionam para todos os
  lançamentos, não apenas os pertencentes a séries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:13:54 +00:00
Felipe Coutinho
cad41680eb feat(pdf): adiciona suporte a visualização de PDF nos anexos
Inclui `pdfjs-dist` como dependência e configura o script `postinstall`
para copiar o web worker necessário para `public/pdf.worker.min.mjs`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:13:39 +00:00
Felipe Coutinho
3b00f328c5 Update version badge to 2.1.2 2026-03-30 15:49:54 -03:00
150 changed files with 4780 additions and 3492 deletions

View File

@@ -7,6 +7,32 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.2.1] - 2026-04-01
### Corrigido
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
## [2.2.0] - 2026-04-01
### Adicionado
- Anexos: nova página de galeria em `/attachments` com miniaturas, visualização inline de imagem e PDF, download direto e acesso a partir do lançamento
- Anexos: suporte a visualização de PDF diretamente no app via `pdfjs-dist`
- Autenticação: sidebar redesenhado com mockup de faturas e três itens de funcionalidade; páginas de login e cadastro ganham gradiente decorativo e logo visível no mobile
- Notificações: alertas de vencimento para boletos e faturas do período seguinte exibidos quando o vencimento está dentro de 5 dias
- Documentação: novo arquivo público `public/llms.txt` com resumo do projeto e links curados para documentação, setup e arquitetura
### Alterado
- Performance: queries de cache do dashboard migradas de `unstable_cache` para a diretiva `use cache` com `cacheTag` e `cacheLife`; todas as páginas do dashboard passam a chamar `connection()` para renderização dinâmica; `next.config.ts` adota `cacheComponents: true`
- Tipografia: adicionada fonte America Medium (weight 500); pesos tipográficos padronizados para `font-medium` em títulos, valores e rótulos em todos os componentes
- Anexos: `AttachmentPreview` foi simplificado para exibir apenas nome da transação, nome do arquivo, navegação entre anexos e ações de download, abrir em nova aba e fechar com ícone `X`
### Corrigido
- Lançamentos: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries
## [2.1.2] - 2026-03-30 ## [2.1.2] - 2026-03-30
### Adicionado ### Adicionado

View File

@@ -44,6 +44,10 @@ Use esta pergunta:
Se um contrato cruza dominios, ele deve morar em `src/shared/`. Se um contrato cruza dominios, ele deve morar em `src/shared/`.
**Excecao intencional: `attachments` depende de `transactions`**
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
Exemplos comuns: Exemplos comuns:
- auth: `src/shared/lib/auth/*` - auth: `src/shared/lib/auth/*`

View File

@@ -13,6 +13,9 @@ WORKDIR /app
# Copiar apenas arquivos de dependências para aproveitar cache # Copiar apenas arquivos de dependências para aproveitar cache
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* ./
# Criar pasta public para o postinstall do pdfjs-dist
RUN mkdir -p public
# Instalar dependências (production + dev para o build) # Instalar dependências (production + dev para o build)
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -56,9 +59,9 @@ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
# Copiar apenas arquivos necessários para produção # Copiar apenas arquivos necessários para produção
COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/package.json ./package.json COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
# Copiar arquivos de build do Next.js # Copiar arquivos de build do Next.js
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
@@ -81,9 +84,6 @@ ENV NODE_ENV=production \
# Expor porta # Expor porta
EXPOSE 3000 EXPOSE 3000
# Ajustar permissões para o usuário nextjs
RUN chown -R nextjs:nodejs /app
# Mudar para usuário não-root # Mudar para usuário não-root
USER nextjs USER nextjs

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.1.1-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.1.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/)

View File

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

View File

@@ -93,12 +93,8 @@
"name": "account_userId_user_id_fk", "name": "account_userId_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -213,12 +209,8 @@
"name": "tokens_api_user_id_user_id_fk", "name": "tokens_api_user_id_user_id_fk",
"tableFrom": "tokens_api", "tableFrom": "tokens_api",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -284,12 +276,8 @@
"name": "anexos_user_id_user_id_fk", "name": "anexos_user_id_user_id_fk",
"tableFrom": "anexos", "tableFrom": "anexos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -299,9 +287,7 @@
"anexos_chave_arquivo_unique": { "anexos_chave_arquivo_unique": {
"name": "anexos_chave_arquivo_unique", "name": "anexos_chave_arquivo_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["chave_arquivo"]
"chave_arquivo"
]
} }
}, },
"policies": {}, "policies": {},
@@ -406,12 +392,8 @@
"name": "orcamentos_user_id_user_id_fk", "name": "orcamentos_user_id_user_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -419,12 +401,8 @@
"name": "orcamentos_categoria_id_categorias_id_fk", "name": "orcamentos_categoria_id_categorias_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -542,12 +520,8 @@
"name": "cartoes_user_id_user_id_fk", "name": "cartoes_user_id_user_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -555,12 +529,8 @@
"name": "cartoes_conta_id_contas_id_fk", "name": "cartoes_conta_id_contas_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -642,12 +612,8 @@
"name": "categorias_user_id_user_id_fk", "name": "categorias_user_id_user_id_fk",
"tableFrom": "categorias", "tableFrom": "categorias",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -763,12 +729,8 @@
"name": "dashboard_notification_states_user_id_user_id_fk", "name": "dashboard_notification_states_user_id_user_id_fk",
"tableFrom": "dashboard_notification_states", "tableFrom": "dashboard_notification_states",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -883,12 +845,8 @@
"name": "contas_user_id_user_id_fk", "name": "contas_user_id_user_id_fk",
"tableFrom": "contas", "tableFrom": "contas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -935,12 +893,8 @@
"name": "import_category_mappings_user_id_user_id_fk", "name": "import_category_mappings_user_id_user_id_fk",
"tableFrom": "import_category_mappings", "tableFrom": "import_category_mappings",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -948,12 +902,8 @@
"name": "import_category_mappings_category_id_categorias_id_fk", "name": "import_category_mappings_category_id_categorias_id_fk",
"tableFrom": "import_category_mappings", "tableFrom": "import_category_mappings",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -961,10 +911,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"import_category_mappings_user_id_description_key_pk": { "import_category_mappings_user_id_description_key_pk": {
"name": "import_category_mappings_user_id_description_key_pk", "name": "import_category_mappings_user_id_description_key_pk",
"columns": [ "columns": ["user_id", "description_key"]
"user_id",
"description_key"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -1120,12 +1067,8 @@
"name": "pre_lancamentos_user_id_user_id_fk", "name": "pre_lancamentos_user_id_user_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1133,12 +1076,8 @@
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk", "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1278,12 +1217,8 @@
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk", "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1291,12 +1226,8 @@
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk", "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1304,12 +1235,8 @@
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk", "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1317,12 +1244,8 @@
"name": "antecipacoes_parcelas_user_id_user_id_fk", "name": "antecipacoes_parcelas_user_id_user_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1452,12 +1375,8 @@
"name": "faturas_user_id_user_id_fk", "name": "faturas_user_id_user_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1465,12 +1384,8 @@
"name": "faturas_cartao_id_cartoes_id_fk", "name": "faturas_cartao_id_cartoes_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -1544,12 +1459,8 @@
"name": "anotacoes_user_id_user_id_fk", "name": "anotacoes_user_id_user_id_fk",
"tableFrom": "anotacoes", "tableFrom": "anotacoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1637,12 +1548,8 @@
"name": "passkey_userId_user_id_fk", "name": "passkey_userId_user_id_fk",
"tableFrom": "passkey", "tableFrom": "passkey",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1725,12 +1632,8 @@
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk", "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1738,12 +1641,8 @@
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk", "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["shared_with_user_id"],
"shared_with_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1751,12 +1650,8 @@
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk", "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["created_by_user_id"],
"created_by_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1912,12 +1807,8 @@
"name": "pagadores_user_id_user_id_fk", "name": "pagadores_user_id_user_id_fk",
"tableFrom": "pagadores", "tableFrom": "pagadores",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2006,12 +1897,8 @@
"name": "insights_salvos_user_id_user_id_fk", "name": "insights_salvos_user_id_user_id_fk",
"tableFrom": "insights_salvos", "tableFrom": "insights_salvos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2081,12 +1968,8 @@
"name": "session_userId_user_id_fk", "name": "session_userId_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2096,9 +1979,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2144,12 +2025,8 @@
"name": "lancamento_anexos_lancamento_id_lancamentos_id_fk", "name": "lancamento_anexos_lancamento_id_lancamentos_id_fk",
"tableFrom": "lancamento_anexos", "tableFrom": "lancamento_anexos",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2157,12 +2034,8 @@
"name": "lancamento_anexos_anexo_id_anexos_id_fk", "name": "lancamento_anexos_anexo_id_anexos_id_fk",
"tableFrom": "lancamento_anexos", "tableFrom": "lancamento_anexos",
"tableTo": "anexos", "tableTo": "anexos",
"columnsFrom": [ "columnsFrom": ["anexo_id"],
"anexo_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2170,10 +2043,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"lancamento_anexos_lancamento_id_anexo_id_pk": { "lancamento_anexos_lancamento_id_anexo_id_pk": {
"name": "lancamento_anexos_lancamento_id_anexo_id_pk", "name": "lancamento_anexos_lancamento_id_anexo_id_pk",
"columns": [ "columns": ["lancamento_id", "anexo_id"]
"lancamento_id",
"anexo_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -2577,12 +2447,8 @@
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk", "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "antecipacoes_parcelas", "tableTo": "antecipacoes_parcelas",
"columnsFrom": [ "columnsFrom": ["antecipacao_id"],
"antecipacao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2590,12 +2456,8 @@
"name": "lancamentos_user_id_user_id_fk", "name": "lancamentos_user_id_user_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2603,12 +2465,8 @@
"name": "lancamentos_cartao_id_cartoes_id_fk", "name": "lancamentos_cartao_id_cartoes_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2616,12 +2474,8 @@
"name": "lancamentos_conta_id_contas_id_fk", "name": "lancamentos_conta_id_contas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2629,12 +2483,8 @@
"name": "lancamentos_categoria_id_categorias_id_fk", "name": "lancamentos_categoria_id_categorias_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2642,12 +2492,8 @@
"name": "lancamentos_pagador_id_pagadores_id_fk", "name": "lancamentos_pagador_id_pagadores_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -2712,9 +2558,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2785,12 +2629,8 @@
"name": "preferencias_usuario_user_id_user_id_fk", "name": "preferencias_usuario_user_id_user_id_fk",
"tableFrom": "preferencias_usuario", "tableFrom": "preferencias_usuario",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2800,9 +2640,7 @@
"preferencias_usuario_user_id_unique": { "preferencias_usuario_user_id_unique": {
"name": "preferencias_usuario_user_id_unique", "name": "preferencias_usuario_user_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id"]
"user_id"
]
} }
}, },
"policies": {}, "policies": {},

View File

@@ -6,9 +6,7 @@ dotenv.config();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
experimental: { cacheComponents: true,
turbopackFileSystemCacheForDev: true,
},
reactCompiler: true, reactCompiler: true,
images: { images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")], remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.1.2", "version": "2.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -16,6 +16,7 @@
"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",
"docker:up": "docker compose up --build", "docker:up": "docker compose up --build",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up:db": "docker compose up -d db", "docker:up:db": "docker compose up -d db",
"docker:up:d": "docker compose up --build -d", "docker:up:d": "docker compose up --build -d",
"docker:down": "docker compose down", "docker:down": "docker compose down",
@@ -72,6 +73,7 @@
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"next": "16.1.7", "next": "16.1.7",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "8.20.0", "pg": "8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",

141
pnpm-lock.yaml generated
View File

@@ -140,6 +140,9 @@ importers:
next-themes: next-themes:
specifier: 0.4.6 specifier: 0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pdfjs-dist:
specifier: ^5.6.205
version: 5.6.205
pg: pg:
specifier: 8.20.0 specifier: 8.20.0
version: 8.20.0 version: 8.20.0
@@ -1291,6 +1294,81 @@ packages:
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
engines: {node: '>=16'} engines: {node: '>=16'}
'@napi-rs/canvas-android-arm64@0.1.97':
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.97':
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.97':
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.97':
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.97':
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
engines: {node: '>= 10'}
'@next/env@16.1.7': '@next/env@16.1.7':
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==} resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
@@ -3366,6 +3444,9 @@ packages:
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-readable-to-web-readable-stream@0.4.2:
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
nypm@0.6.5: nypm@0.6.5:
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3388,6 +3469,10 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@5.6.205:
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -4936,6 +5021,54 @@ snapshots:
lilconfig: 2.1.0 lilconfig: 2.1.0
optional: true optional: true
'@napi-rs/canvas-android-arm64@0.1.97':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.97':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.97':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
optional: true
'@napi-rs/canvas@0.1.97':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.97
'@napi-rs/canvas-darwin-arm64': 0.1.97
'@napi-rs/canvas-darwin-x64': 0.1.97
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
'@napi-rs/canvas-linux-arm64-gnu': 0.1.97
'@napi-rs/canvas-linux-arm64-musl': 0.1.97
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-musl': 0.1.97
'@napi-rs/canvas-win32-arm64-msvc': 0.1.97
'@napi-rs/canvas-win32-x64-msvc': 0.1.97
optional: true
'@next/env@16.1.7': {} '@next/env@16.1.7': {}
'@next/swc-darwin-arm64@16.1.7': '@next/swc-darwin-arm64@16.1.7':
@@ -7141,6 +7274,9 @@ snapshots:
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
optional: true optional: true
node-readable-to-web-readable-stream@0.4.2:
optional: true
nypm@0.6.5: nypm@0.6.5:
dependencies: dependencies:
citty: 0.2.1 citty: 0.2.1
@@ -7161,6 +7297,11 @@ snapshots:
pathe@2.0.3: pathe@2.0.3:
optional: true optional: true
pdfjs-dist@5.6.205:
optionalDependencies:
'@napi-rs/canvas': 0.1.97
node-readable-to-web-readable-stream: 0.4.2
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
optional: true optional: true

Binary file not shown.

Binary file not shown.

View File

@@ -7,6 +7,11 @@ export const america = localFont({
weight: "400", weight: "400",
style: "normal", style: "normal",
}, },
{
path: "./america-medium.woff2",
weight: "500",
style: "normal",
},
], ],
display: "swap", display: "swap",
variable: "--font-america", variable: "--font-america",

37
public/llms.txt Normal file
View File

@@ -0,0 +1,37 @@
# OpenMonetis
> OpenMonetis is a self-hosted personal finance web app for manual financial control. It helps users manage accounts, cards, invoices, budgets, notes, reports, attachments, and AI-generated insights. The product UI is in Brazilian Portuguese, the codebase uses English folder and import names, and there is no hosted SaaS version.
>
> **Stack:** Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind CSS 4, shadcn/ui. Package manager: pnpm. Linter: Biome.
OpenMonetis is meant to be deployed by the user on their own machine or server.
There is no Open Finance or automatic bank synchronization.
Transactions can be entered manually or imported from OFX and XLS/XLSX files.
Attachments are optional and require S3-compatible storage.
The public website is mainly a landing page; the main technical documentation lives in the GitHub repository.
## Docs
- [Landing page](/): Public homepage and high-level product overview
- [README](https://github.com/felipegcoutinho/openmonetis/blob/main/README.md): Main project documentation covering features, installation, Docker, environment variables, architecture, contributing, and license
- [CHANGELOG](https://github.com/felipegcoutinho/openmonetis/blob/main/CHANGELOG.md): Release history and notable changes
- [LICENSE](https://github.com/felipegcoutinho/openmonetis/blob/main/LICENSE): CC BY-NC-SA 4.0 license terms
## Setup
- [Setup script](https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs): Interactive installer for local or self-hosted setup
- [Environment example](https://github.com/felipegcoutinho/openmonetis/blob/main/.env.example): Required and optional environment variables
- [Docker Compose](https://github.com/felipegcoutinho/openmonetis/blob/main/docker-compose.yml): Local app and PostgreSQL stack definition
## Architecture
- [CLAUDE.md](https://github.com/felipegcoutinho/openmonetis/blob/main/CLAUDE.md): Project architecture, naming rules, query rules, and feature checklist
## Optional
- [robots.txt](/robots.txt): Crawl policy for the public site
## Related Projects
- [OpenMonetis Companion](https://github.com/felipegcoutinho/openmonetis-companion): Android app that captures bank notifications and sends them to the OpenMonetis inbox for review

28
public/pdf.worker.min.mjs Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
import type { Account } from "@/features/accounts/components/types"; import type { Account } from "@/features/accounts/components/types";
@@ -42,6 +43,7 @@ const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { accountId } = await params; const { accountId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { AccountsPage } from "@/features/accounts/components/accounts-page"; import { AccountsPage } from "@/features/accounts/components/accounts-page";
import { fetchAllAccountsForUser } from "@/features/accounts/queries"; import { fetchAllAccountsForUser } from "@/features/accounts/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { activeAccounts, archivedAccounts, logoOptions } = const { activeAccounts, archivedAccounts, logoOptions } =
await fetchAllAccountsForUser(userId); await fetchAllAccountsForUser(userId);

View File

@@ -0,0 +1,38 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function AnexosLoading() {
return (
<main className="flex flex-col gap-6">
<div className="w-full space-y-6">
{/* Header */}
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
{/* Month navigation */}
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
{/* Count */}
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
{/* Grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className="flex flex-col overflow-hidden rounded-lg border"
>
<Skeleton className="aspect-square w-full bg-foreground/10" />
<div className="space-y-1.5 p-2.5">
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
<div className="flex justify-between">
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
</div>
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,36 @@
import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period } = parsePeriodParam(periodoParam);
const attachments = await fetchAttachmentsForPeriod(userId, period);
return (
<main className="flex flex-col gap-6">
<AttachmentsPage attachments={attachments} />
</main>
);
}

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { BudgetsPage } from "@/features/budgets/components/budgets-page"; import { BudgetsPage } from "@/features/budgets/components/budgets-page";
import { fetchBudgetsForUser } from "@/features/budgets/queries"; import { fetchBudgetsForUser } from "@/features/budgets/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
@@ -23,6 +24,7 @@ const capitalize = (value: string) =>
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1); value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar"; import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
import { fetchCalendarData } from "@/features/calendar/queries"; import { fetchCalendarData } from "@/features/calendar/queries";
import { import {
@@ -16,6 +17,7 @@ type PageProps = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined; const resolvedParams = searchParams ? await searchParams : undefined;

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import type { FinancialAccount } from "@/db/schema"; import type { FinancialAccount } from "@/db/schema";
import { CardDialog } from "@/features/cards/components/card-dialog"; import { CardDialog } from "@/features/cards/components/card-dialog";
import type { Card } from "@/features/cards/components/types"; import type { Card } from "@/features/cards/components/types";
@@ -39,6 +40,7 @@ type PageProps = {
}; };
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { cardId } = await params; const { cardId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { CardsPage } from "@/features/cards/components/cards-page"; import { CardsPage } from "@/features/cards/components/cards-page";
import { fetchAllCardsForUser } from "@/features/cards/queries"; import { fetchAllCardsForUser } from "@/features/cards/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { activeCards, archivedCards, accounts, logoOptions } = const { activeCards, archivedCards, accounts, logoOptions } =
await fetchAllCardsForUser(userId); await fetchAllCardsForUser(userId);

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header"; import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries"; import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
import { fetchUserPreferences } from "@/features/settings/queries"; import { fetchUserPreferences } from "@/features/settings/queries";
@@ -32,6 +33,7 @@ const getSingleParam = (
}; };
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { categoryId } = await params; const { categoryId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -1,9 +1,11 @@
import { connection } from "next/server";
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries"; import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget"; import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { getCurrentPeriod } from "@/shared/utils/period"; import { getCurrentPeriod } from "@/shared/utils/period";
export default async function HistoricoCategoriasPage() { export default async function HistoricoCategoriasPage() {
await connection();
const user = await getUser(); const user = await getUser();
const currentPeriod = getCurrentPeriod(); const currentPeriod = getCurrentPeriod();

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { CategoriesPage } from "@/features/categories/components/categories-page"; import { CategoriesPage } from "@/features/categories/components/categories-page";
import { fetchCategoriesForUser } from "@/features/categories/queries"; import { fetchCategoriesForUser } from "@/features/categories/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId); const categories = await fetchCategoriesForUser(userId);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable"; import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards"; import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
@@ -14,6 +15,7 @@ type PageProps = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { InboxPage } from "@/features/inbox/components/inbox-page"; import { InboxPage } from "@/features/inbox/components/inbox-page";
import { import {
type ResolvedInboxSearchParams, type ResolvedInboxSearchParams,
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams); const activeStatus = resolveInboxStatus(resolvedSearchParams);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { InsightsPage } from "@/features/insights/components/insights-page"; import { InsightsPage } from "@/features/insights/components/insights-page";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
@@ -18,6 +19,7 @@ const getSingleParam = (
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
@@ -9,6 +10,7 @@ export default async function DashboardLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
await connection();
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id); const navbarData = await fetchDashboardNavbarData(session.user.id);

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { NotesPage } from "@/features/notes/components/notes-page"; import { NotesPage } from "@/features/notes/components/notes-page";
import { fetchAllNotesForUser } from "@/features/notes/queries"; import { fetchAllNotesForUser } from "@/features/notes/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId); const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);

View File

@@ -4,6 +4,7 @@ import {
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card"; import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card"; import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card"; import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
type OptionSet = ReturnType<typeof buildOptionSets>; type OptionSet = ReturnType<typeof buildOptionSets>;
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { payerId } = await params; const { payerId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { PayersPage } from "@/features/payers/components/payers-page"; import { PayersPage } from "@/features/payers/components/payers-page";
import { fetchPayersForUser } from "@/features/payers/queries"; import { fetchPayersForUser } from "@/features/payers/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { payers, avatarOptions } = await fetchPayersForUser(userId); const { payers, avatarOptions } = await fetchPayersForUser(userId);

View File

@@ -1,4 +1,5 @@
import { RiBankCard2Line } from "@remixicon/react"; import { RiBankCard2Line } from "@remixicon/react";
import { connection } from "next/server";
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries"; import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown"; import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status"; import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
@@ -28,6 +29,7 @@ const getSingleParam = (
export default async function RelatorioCartoesPage({ export default async function RelatorioCartoesPage({
searchParams, searchParams,
}: PageProps) { }: PageProps) {
await connection();
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { connection } from "next/server";
import type { Category } from "@/db/schema"; import type { Category } from "@/db/schema";
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries"; import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
import { fetchCategoryReport } from "@/features/reports/category-report-queries"; import { fetchCategoryReport } from "@/features/reports/category-report-queries";
@@ -29,6 +30,7 @@ const getSingleParam = (
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
// Get authenticated user // Get authenticated user
const userId = await getUserId(); const userId = await getUserId();

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list"; import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards"; import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter"; import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
export default async function TopEstabelecimentosPage({ export default async function TopEstabelecimentosPage({
searchParams, searchParams,
}: PageProps) { }: PageProps) {
await connection();
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page"; import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries"; import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const user = await getUser(); const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id); const data = await fetchInstallmentAnalysis(user.id);

View File

@@ -1,6 +1,7 @@
import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react"; import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { connection } from "next/server";
import { CompanionTab } from "@/features/settings/components/companion-tab"; import { CompanionTab } from "@/features/settings/components/companion-tab";
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form"; import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
@@ -21,6 +22,7 @@ import {
import { auth } from "@/shared/lib/auth/config"; import { auth } from "@/shared/lib/auth/config";
export default async function Page() { export default async function Page() {
await connection();
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
@@ -65,7 +67,7 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold mb-1">Preferências</h2> <h2 className="text-xl font-medium mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades. configurações de acordo com suas necessidades.
@@ -90,7 +92,7 @@ export default async function Page() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-bold">OpenMonetis Companion</h2> <h2 className="text-xl font-medium">OpenMonetis Companion</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10"> <span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" /> <RiAndroidLine className="h-3 w-3" />
Android Android
@@ -112,7 +114,7 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold mb-1">Alterar nome</h2> <h2 className="text-xl font-medium mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações. ser exibido em diferentes seções do app e em comunicações.
@@ -128,7 +130,7 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold mb-1">Alterar senha</h2> <h2 className="text-xl font-medium mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local Defina uma nova senha para sua conta. Guarde-a em local
seguro. seguro.
@@ -144,7 +146,7 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold mb-1">Passkeys</h2> <h2 className="text-xl font-medium mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID, Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança. Touch ID, Windows Hello) ou chaves de segurança.
@@ -160,7 +162,7 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold mb-1">Alterar e-mail</h2> <h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail confirmar os links enviados para o novo e também para o e-mail
@@ -180,7 +182,7 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold mb-1 text-destructive"> <h2 className="text-xl font-medium mb-1 text-destructive">
Ações perigosas Ações perigosas
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { ImportPage } from "@/features/transactions/components/import/import-page"; import { ImportPage } from "@/features/transactions/components/import/import-page";
import { import {
buildOptionSets, buildOptionSets,
@@ -7,6 +8,7 @@ import { fetchTransactionFilterSources } from "@/features/transactions/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const filterSources = await fetchTransactionFilterSources(userId); const filterSources = await fetchTransactionFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources); const sluggedFilters = buildSluggedFilters(filterSources);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { fetchUserPreferences } from "@/features/settings/queries"; import { fetchUserPreferences } from "@/features/settings/queries";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page"; import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { import {
@@ -27,6 +28,7 @@ type PageProps = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -126,7 +126,7 @@ export default async function Page() {
Projeto Open Source Projeto Open Source
</Badge> </Badge>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight"> <h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
Suas finanças, Suas finanças,
<span className="text-primary"> do seu jeito</span> <span className="text-primary"> do seu jeito</span>
</h1> </h1>
@@ -207,7 +207,7 @@ export default async function Page() {
className="flex flex-col items-center text-center gap-1.5" className="flex flex-col items-center text-center gap-1.5"
> >
<Icon className="size-5" style={{ color: colorVar }} /> <Icon className="size-5" style={{ color: colorVar }} />
<span className="text-2xl md:text-3xl font-bold"> <span className="text-2xl md:text-3xl font-medium">
{value} {value}
</span> </span>
<span className="text-xs md:text-sm text-muted-foreground"> <span className="text-xs md:text-sm text-muted-foreground">
@@ -229,7 +229,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Conheça as telas Conheça as telas
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Veja o que você pode fazer Veja o que você pode fazer
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -254,7 +254,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
O que tem aqui O que tem aqui
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Funcionalidades que importam Funcionalidades que importam
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -282,7 +282,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2"> <h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
@@ -346,7 +346,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" /> <RiSmartphoneLine className="size-3.5 mr-1" />
Mobile Mobile
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Use o OpenMonetis no celular sem perder o fluxo Use o OpenMonetis no celular sem perder o fluxo
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -384,7 +384,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" /> <RiSmartphoneLine className="size-3.5 mr-1" />
PWA instalável PWA instalável
</Badge> </Badge>
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3"> <h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
Leve o OpenMonetis para a tela inicial Leve o OpenMonetis para a tela inicial
</h3> </h3>
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
@@ -430,7 +430,7 @@ export default async function Page() {
Companion Android Companion Android
</Badge> </Badge>
</div> </div>
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3"> <h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
Capture, envie e revise no mesmo fluxo Capture, envie e revise no mesmo fluxo
</h3> </h3>
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
@@ -441,7 +441,7 @@ export default async function Page() {
{companionSteps.map((step, index) => ( {companionSteps.map((step, index) => (
<li key={step.title} className="flex items-start gap-3"> <li key={step.title} className="flex items-start gap-3">
<span <span
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
color: step.colorVar, color: step.colorVar,
@@ -529,7 +529,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Stack técnica Stack técnica
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
O que roda por baixo O que roda por baixo
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -556,7 +556,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2"> <h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
{item.title} {item.title}
</h3> </h3>
<p className="text-sm text-muted-foreground mb-2 md:mb-3"> <p className="text-sm text-muted-foreground mb-2 md:mb-3">
@@ -582,7 +582,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Como usar Como usar
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Rode no seu computador Rode no seu computador
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -617,7 +617,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Para quem é? Para quem é?
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Feito para quem gosta de controle Feito para quem gosta de controle
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -644,7 +644,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold mb-1">{item.title}</h3> <h3 className="font-medium mb-1">{item.title}</h3>
<p className="text-xs sm:text-sm text-muted-foreground"> <p className="text-xs sm:text-sm text-muted-foreground">
{item.description} {item.description}
</p> </p>
@@ -664,7 +664,7 @@ export default async function Page() {
<div className="max-w-8xl mx-auto px-4"> <div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll> <AnimateOnScroll>
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center"> <div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Pronto para testar? Pronto para testar?
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8"> <p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
@@ -715,7 +715,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3> <h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground"> <ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li> <li>
<Link <Link
@@ -749,7 +749,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3> <h3 className="font-medium mb-3 md:mb-4">Companion</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground"> <ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li> <li>
<Link <Link

View File

@@ -0,0 +1,27 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { attachments } from "@/db/schema";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ attachmentId: string }> },
) {
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
const [row] = await db
.select({ fileKey: attachments.fileKey })
.from(attachments)
.where(
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
);
if (!row) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const url = await createPresignedGetUrl(row.fileKey);
return NextResponse.json({ url });
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react";
import { ThemeProvider } from "@/shared/components/providers/theme-provider"; import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner"; import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css"; import "./globals.css";
@@ -33,9 +34,9 @@ export default function RootLayout({
data-domains="openmonetis.com" data-domains="openmonetis.com"
/> />
</head> </head>
<body className="subpixel-antialiased" suppressHydrationWarning> <body className="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light"> <ThemeProvider attribute="class" defaultTheme="light">
{children} <Suspense>{children}</Suspense>
<Toaster position="top-right" /> <Toaster position="top-right" />
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@@ -1,9 +1,5 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: [ rules: [
@@ -21,15 +17,15 @@ export default function robots(): MetadataRoute.Robots {
"/notes", "/notes",
"/insights", "/insights",
"/calendar", "/calendar",
"/consultor", "/attachments",
"/settings", "/settings",
"/reports", "/reports",
"/inbox", "/inbox",
"/login", "/login",
"/signup",
"/api/", "/api/",
], ],
}, },
], ],
sitemap: `${BASE_URL}/sitemap.xml`,
}; };
} }

View File

@@ -88,9 +88,7 @@ export function AccountCard({
{icon} {icon}
</div> </div>
) : null} ) : null}
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-medium text-foreground">{accountName}</h2>
{accountName}
</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && ( {(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip> <Tooltip>

View File

@@ -68,7 +68,7 @@ export function AccountStatementCard({
</div> </div>
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-sm font-medium text-foreground">
{accountName} {accountName}
</h2> </h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -86,12 +86,12 @@ export function AccountStatementCard({
</p> </p>
<MoneyValues <MoneyValues
amount={currentBalance} amount={currentBalance}
className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]" className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={getAccountStatusBadgeVariant(status)} variant={getAccountStatusBadgeVariant(status)}
className="text-[11px]" className="text-xs"
> >
{status} {status}
</Badge> </Badge>
@@ -107,7 +107,7 @@ export function AccountStatementCard({
label="Saldo inicial" label="Saldo inicial"
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês." tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
> >
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-medium text-foreground">
{formatCurrency(openingBalance)} {formatCurrency(openingBalance)}
</span> </span>
</MetaItem> </MetaItem>
@@ -116,7 +116,7 @@ export function AccountStatementCard({
label="Entradas" label="Entradas"
tooltip="Total de receitas deste mês classificadas como pagas para esta conta." tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
> >
<span className="text-sm font-semibold text-success"> <span className="text-sm font-medium text-success">
{formatCurrency(totalIncomes)} {formatCurrency(totalIncomes)}
</span> </span>
</MetaItem> </MetaItem>
@@ -125,7 +125,7 @@ export function AccountStatementCard({
label="Saídas" label="Saídas"
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)." tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
> >
<span className="text-sm font-semibold text-destructive"> <span className="text-sm font-medium text-destructive">
{formatCurrency(totalExpenses)} {formatCurrency(totalExpenses)}
</span> </span>
</MetaItem> </MetaItem>
@@ -136,7 +136,7 @@ export function AccountStatementCard({
> >
<span <span
className={cn( className={cn(
"text-sm font-semibold", "text-sm font-medium",
resultado >= 0 ? "text-success" : "text-destructive", resultado >= 0 ? "text-success" : "text-destructive",
)} )}
> >

View File

@@ -0,0 +1,208 @@
"use client";
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDate } from "@/shared/utils/date";
import { formatBytes } from "@/shared/utils/number";
interface PdfCanvasProps {
url: string;
}
function PdfCanvas({ url }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [locked, setLocked] = useState(false);
useEffect(() => {
let cancelled = false;
setLocked(false);
async function render() {
const pdfjsLib = await import("pdfjs-dist");
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
try {
pdf = await pdfjsLib.getDocument(url).promise;
} catch (err) {
if ((err as { name?: string }).name === "PasswordException") {
if (!cancelled) setLocked(true);
}
return;
}
const page = await pdf.getPage(1);
const canvas = canvasRef.current;
if (!canvas || cancelled) return;
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
const viewport = page.getViewport({ scale: 1 });
const scale = containerWidth / viewport.width;
const scaled = page.getViewport({ scale });
canvas.width = scaled.width;
canvas.height = scaled.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
.promise;
}
render().catch(() => {});
return () => {
cancelled = true;
};
}, [url]);
if (locked) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
<span className="text-xs font-medium text-muted-foreground/60">
PDF Protegido
</span>
</div>
);
}
return (
<canvas
ref={canvasRef}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
);
}
interface AttachmentGridItemProps {
attachment: AttachmentForPeriod;
url?: string;
onClick: () => void;
onDetails: () => void;
isLoadingDetails?: boolean;
}
export function AttachmentGridItem({
attachment,
url,
onClick,
onDetails,
isLoadingDetails = false,
}: AttachmentGridItemProps) {
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
const amount = Number.parseFloat(attachment.transactionAmount);
return (
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
{/* Thumbnail */}
<button
type="button"
onClick={onClick}
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
>
{/* Conteúdo do thumbnail */}
{isImage && url && (
<Image
src={url}
alt={attachment.fileName}
fill
unoptimized
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
)}
{isImage && !url && (
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
)}
{isPdf && url && <PdfCanvas url={url} />}
{isPdf && !url && (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
<RiFilePdf2Line className="size-14 text-red-400/60" />
</div>
)}
{!isImage && !isPdf && (
<div className="flex h-full w-full items-center justify-center bg-muted">
<RiFileLine className="size-14 text-muted-foreground/40" />
</div>
)}
{/* Overlay no hover */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
</button>
{/* Informações */}
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
{/* Nome do arquivo + tipo */}
<div className="flex items-center gap-1 min-w-0">
<div className="shrink-0 gap-0.5 text-xs opacity-60">
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
{isImage && <RiImageLine className="size-4 text-blue-500" />}
{!isPdf && !isImage && <RiFileLine className="size-4" />}
</div>
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm font-medium leading-tight text-foreground">
{attachment.fileName}
</p>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{attachment.fileName}
</TooltipContent>
</Tooltip>
</div>
{/* Data */}
<span className="text-xs text-muted-foreground">
{formatDate(attachment.purchaseDate)}
</span>
{/* Transação e Valor */}
<div className="flex items-start justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm text-muted-foreground">
{attachment.transactionName}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{attachment.transactionName}
</TooltipContent>
</Tooltip>
<span
className={cn(
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
)}
>
{formatCurrency(amount)}
</span>
</div>
{/* Footer: Tamanho + Botão Detalhes */}
<div className="mt-auto flex items-center justify-between border-t pt-3">
<span className="text-xs font-medium text-muted-foreground/70">
{formatBytes(attachment.fileSize)}
</span>
<button
type="button"
onClick={onDetails}
disabled={isLoadingDetails}
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
>
{isLoadingDetails ? "Carregando..." : "Detalhes"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiCloseLine,
RiDownloadLine,
RiExternalLinkLine,
} from "@remixicon/react";
import { useEffect, useState } from "react";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
interface AttachmentPreviewProps {
attachments: AttachmentForPeriod[];
selectedIndex: number;
onClose: () => void;
}
export function AttachmentPreview({
attachments,
selectedIndex,
onClose,
}: AttachmentPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const open = selectedIndex >= 0;
useEffect(() => {
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
}, [selectedIndex]);
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
if (e.key === "ArrowRight")
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, attachments.length]);
const attachment = attachments[currentIndex];
const attachmentId = attachment?.attachmentId;
// Busca URL fresca a cada troca de anexo
useEffect(() => {
if (!attachmentId) return;
setPreviewUrl(null);
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setPreviewUrl(data.url))
.catch(() => {});
}, [attachmentId]);
if (!attachment) return null;
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < attachments.length - 1;
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DialogContent
showCloseButton={false}
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
>
<DialogHeader className="flex-row items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="min-w-0 space-y-0.5">
<DialogTitle
className="truncate text-sm font-medium"
title={attachment.transactionName}
>
{attachment.transactionName}
</DialogTitle>
<p
className="truncate text-xs text-muted-foreground"
title={attachment.fileName}
>
{attachment.fileName}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{attachments.length > 1 && (
<>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!hasPrev}
onClick={() => setCurrentIndex((i) => i - 1)}
title="Anterior (←)"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<span className="select-none text-xs text-muted-foreground tabular-nums">
{currentIndex + 1} / {attachments.length}
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!hasNext}
onClick={() => setCurrentIndex((i) => i + 1)}
title="Próximo (→)"
>
<RiArrowRightSLine className="size-4" />
</Button>
</>
)}
<Button
type="button"
variant="ghost"
size="icon"
disabled={!previewUrl}
asChild={!!previewUrl}
>
{previewUrl ? (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
download={attachment.fileName}
>
<RiDownloadLine className="size-4" />
</a>
) : (
<RiDownloadLine className="size-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!previewUrl}
asChild={!!previewUrl}
>
{previewUrl ? (
<a href={previewUrl} target="_blank" rel="noreferrer">
<RiExternalLinkLine className="size-4" />
</a>
) : (
<RiExternalLinkLine className="size-4" />
)}
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="icon">
<RiCloseLine className="size-4" />
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{!previewUrl && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div>
)}
{isPdf && previewUrl && (
<iframe
key={attachment.attachmentId}
src={previewUrl}
className="h-full w-full border-0 bg-background"
title={attachment.fileName}
/>
)}
{isImage && previewUrl && (
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={attachment.attachmentId}
src={previewUrl}
alt={attachment.fileName}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import {
RiAttachmentLine,
RiFilePdf2Line,
RiImageLine,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import type React from "react";
import { useState, useTransition } from "react";
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/empty-state";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui";
type FilterType = "all" | "images" | "pdfs";
function AttachmentGridItemWithUrl({
attachment,
onClick,
onDetails,
isLoadingDetails,
}: {
attachment: AttachmentForPeriod;
onClick: () => void;
onDetails: () => void;
isLoadingDetails: boolean;
}) {
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
return (
<div ref={containerRef}>
<AttachmentGridItem
attachment={attachment}
url={url ?? undefined}
onClick={onClick}
onDetails={onDetails}
isLoadingDetails={isLoadingDetails}
/>
</div>
);
}
const FILTERS: {
value: FilterType;
label: string;
icon: React.ReactNode;
}[] = [
{
value: "all",
label: "Todos",
icon: <RiAttachmentLine className="size-3.5" />,
},
{
value: "images",
label: "Imagens",
icon: <RiImageLine className="size-3.5 text-blue-500" />,
},
{
value: "pdfs",
label: "PDFs",
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
},
];
interface AttachmentsPageProps {
attachments: AttachmentForPeriod[];
}
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all");
const [selectedIndex, setSelectedIndex] = useState(-1);
const [transactionDetails, setTransactionDetails] =
useState<TransactionItem | null>(null);
const [loadingTransactionId, setLoadingTransactionId] = useState<
string | null
>(null);
const [isPending, startTransition] = useTransition();
// Edit dialog state
const [editOpen, setEditOpen] = useState(false);
const [transactionToEdit, setTransactionToEdit] =
useState<TransactionItem | null>(null);
const [dialogOptions, setDialogOptions] =
useState<TransactionDialogOptions | null>(null);
const filteredAttachments = attachments.filter((a) => {
if (filter === "images") return a.mimeType.startsWith("image/");
if (filter === "pdfs") return a.mimeType === "application/pdf";
return true;
});
const imageCount = attachments.filter((a) =>
a.mimeType.startsWith("image/"),
).length;
const pdfCount = attachments.filter(
(a) => a.mimeType === "application/pdf",
).length;
const counts: Record<FilterType, number> = {
all: attachments.length,
images: imageCount,
pdfs: pdfCount,
};
function handleSelect(attachment: AttachmentForPeriod) {
const idx = filteredAttachments.findIndex(
(a) =>
a.attachmentId === attachment.attachmentId &&
a.transactionId === attachment.transactionId,
);
setSelectedIndex(idx);
}
function handleDetails(transactionId: string) {
setLoadingTransactionId(transactionId);
startTransition(async () => {
const transaction = await fetchTransactionByIdAction(transactionId);
setLoadingTransactionId(null);
if (transaction) setTransactionDetails(transaction);
});
}
function handleEdit(transaction: TransactionItem) {
setTransactionToEdit(transaction);
startTransition(async () => {
const options = await fetchTransactionDialogOptionsAction();
setDialogOptions(options);
setEditOpen(true);
});
}
return (
<div className="w-full space-y-6">
<PageDescription
icon={<RiAttachmentLine className="size-5" />}
title="Anexos"
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
/>
<MonthNavigation />
<Card>
<CardContent>
{attachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo neste mês"
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
/>
</div>
) : (
<div className="space-y-4">
{/* Header: filtros + contagem */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{filteredAttachments.length}{" "}
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
{filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p>
<div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => (
<button
key={value}
type="button"
onClick={() => {
setFilter(value);
setSelectedIndex(-1);
}}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
filter === value
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className={cn(filter !== value && "opacity-60")}>
{icon}
</span>
{label}{" "}
<span
className={cn(
"tabular-nums",
filter === value ? "opacity-80" : "opacity-60",
)}
>
({counts[value]})
</span>
</button>
))}
</div>
</div>
{filteredAttachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo encontrado"
description="Não há anexos do tipo selecionado neste mês."
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredAttachments.map((attachment) => (
<AttachmentGridItemWithUrl
key={`${attachment.attachmentId}-${attachment.transactionId}`}
attachment={attachment}
onClick={() => handleSelect(attachment)}
onDetails={() => handleDetails(attachment.transactionId)}
isLoadingDetails={
isPending &&
loadingTransactionId === attachment.transactionId
}
/>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
<AttachmentPreview
attachments={filteredAttachments}
selectedIndex={selectedIndex}
onClose={() => setSelectedIndex(-1)}
/>
<TransactionDetailsDialog
open={!!transactionDetails}
onOpenChange={(open) => {
if (!open) setTransactionDetails(null);
}}
transaction={transactionDetails}
onEdit={handleEdit}
/>
{dialogOptions && transactionToEdit && (
<TransactionDialog
mode="update"
open={editOpen}
onOpenChange={(open) => {
setEditOpen(open);
if (!open) {
setTransactionToEdit(null);
setDialogOptions(null);
router.refresh();
}
}}
transaction={transactionToEdit}
payerOptions={dialogOptions.payerOptions}
splitPayerOptions={dialogOptions.splitPayerOptions}
defaultPayerId={dialogOptions.defaultPayerId}
accountOptions={dialogOptions.accountOptions}
cardOptions={dialogOptions.cardOptions}
categoryOptions={dialogOptions.categoryOptions}
estabelecimentos={dialogOptions.estabelecimentos}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function useAttachmentUrl(attachmentId: string) {
const [url, setUrl] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setUrl(null);
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setUrl(data.url))
.catch(() => {});
},
{ rootMargin: "150px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [attachmentId]);
return { url, containerRef };
}

View File

@@ -0,0 +1,70 @@
import { and, desc, eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import {
attachments,
categories,
transactionAttachments,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AttachmentForPeriod = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
transactionId: string;
transactionName: string;
transactionAmount: string;
transactionPeriod: string;
purchaseDate: Date;
categoryName: string | null;
categoryIcon: string | null;
};
export async function fetchAttachmentsForPeriod(
userId: string,
period: string,
): Promise<AttachmentForPeriod[]> {
"use cache";
cacheTag(`dashboard-${userId}`);
cacheLife({ revalidate: 3 });
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
return db
.select({
attachmentId: attachments.id,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
transactionId: transactions.id,
transactionName: transactions.name,
transactionAmount: transactions.amount,
transactionPeriod: transactions.period,
purchaseDate: transactions.purchaseDate,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(transactionAttachments)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
eq(transactions.period, period),
),
)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
}

View File

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

View File

@@ -0,0 +1,89 @@
import Image from "next/image";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
type MockInvoice = {
cardName: string;
logo: string;
amount: number;
dueLabel: string;
};
const MOCK_INVOICES: MockInvoice[] = [
{
cardName: "Nubank",
logo: "nubank.png",
amount: 1898,
dueLabel: "Vence em 3 dias",
},
{
cardName: "Itaú",
logo: "itau.png",
amount: 1923,
dueLabel: "Vence em 8 dias",
},
];
function MockInvoiceItem({
invoice,
divider,
}: {
invoice: MockInvoice;
divider: boolean;
}) {
const logoSrc = resolveLogoSrc(invoice.logo);
return (
<div className={divider ? "border-b border-border/60" : undefined}>
<div className="flex items-center justify-between py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-0.5">
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full">
{logoSrc && (
<Image
src={logoSrc}
alt={`Logo ${invoice.cardName}`}
width={36}
height={36}
className="h-full w-full object-contain"
/>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{invoice.cardName}
</p>
<p className="text-xs text-muted-foreground">{invoice.dueLabel}</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<span className="text-sm font-medium tracking-tighter text-foreground">
{formatCurrency(invoice.amount)}
</span>
<span className="text-xs font-medium text-primary">Pagar</span>
</div>
</div>
</div>
);
}
export function AuthSidebarInvoicesMock() {
return (
<div className="rounded-xl border bg-card shadow-sm">
<div className="border-b px-4 py-3">
<span className="text-sm font-medium text-foreground">Faturas</span>
<p className="mt-0.5 text-xs text-muted-foreground">
Resumo das faturas do período
</p>
</div>
<div className="px-4">
{MOCK_INVOICES.map((invoice, index) => (
<MockInvoiceItem
key={invoice.cardName}
invoice={invoice}
divider={index < MOCK_INVOICES.length - 1}
/>
))}
</div>
</div>
);
}

View File

@@ -1,5 +1,28 @@
import {
RiBankCardLine,
RiBarChart2Line,
RiShieldCheckLine,
} from "@remixicon/react";
import { Logo } from "@/shared/components/logo"; import { Logo } from "@/shared/components/logo";
import { DotPattern } from "@/shared/components/ui/dot-pattern"; import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { AuthSidebarInvoicesMock } from "./auth-sidebar-invoices-mock";
function FeatureItem({
icon: Icon,
text,
}: {
icon: React.ComponentType<{ className?: string }>;
text: string;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/12">
<Icon className="h-3.5 w-3.5 text-black/55" />
</div>
<span className="text-sm font-medium text-black/68">{text}</span>
</div>
);
}
function AuthSidebar() { function AuthSidebar() {
return ( return (
@@ -15,6 +38,7 @@ function AuthSidebar() {
/> />
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" /> <div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
</div> </div>
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12"> <div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
<Logo <Logo
variant="compact" variant="compact"
@@ -22,14 +46,25 @@ function AuthSidebar() {
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0" className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
/> />
<div className="max-w-sm space-y-4.5"> <div className="flex flex-1 items-center justify-center py-10">
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]"> <div className="w-full rotate-[1.5deg]">
Controle suas finanças com clareza e foco diário. <AuthSidebarInvoicesMock />
</h2> </div>
<p className="max-w-2xs text-sm leading-6 text-black/68"> </div>
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia. <div className="space-y-3">
</p> <FeatureItem
icon={RiBarChart2Line}
text="Controle de gastos por categoria"
/>
<FeatureItem
icon={RiBankCardLine}
text="Faturas e cartões centralizados"
/>
<FeatureItem
icon={RiShieldCheckLine}
text="Seus dados, sem rastreamento"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -240,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
</a> </a>
</FieldDescription> </FieldDescription>
<FieldDescription className="text-center text-[13px] text-muted-foreground"> <FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}> <a href="/" className={authLinkClassName}>
Voltar para a página inicial Voltar para a página inicial
</a> </a>

View File

@@ -277,7 +277,7 @@ export function SignupForm({ className, ...props }: DivProps) {
</a> </a>
</FieldDescription> </FieldDescription>
<FieldDescription className="text-center text-[13px] text-muted-foreground"> <FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}> <a href="/" className={authLinkClassName}>
Voltar para a página inicial Voltar para a página inicial
</a> </a>

View File

@@ -52,7 +52,7 @@ export function BudgetCard({
size="lg" size="lg"
/> />
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-base font-semibold leading-tight"> <h3 className="text-base font-medium leading-tight">
{formatCategoryName(budget)} {formatCategoryName(budget)}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -244,7 +244,7 @@ export function BudgetDialog({
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Limite atual</span> <span className="text-muted-foreground">Limite atual</span>
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{formatCurrency(sliderValue)} {formatCurrency(sliderValue)}
</span> </span>
</div> </div>

View File

@@ -19,7 +19,7 @@ export function CalendarGrid({
}: CalendarGridProps) { }: CalendarGridProps) {
return ( return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none"> <div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <div className="grid grid-cols-7 text-sm font-medium uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => ( {WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center text-primary"> <span key={dayName} className="px-3 py-2 text-center text-primary">
{dayName} {dayName}

View File

@@ -110,9 +110,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</div> </div>
{complement ? ( {complement ? (
<span <span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
>
{complement} {complement}
</span> </span>
) : null} ) : null}
@@ -153,7 +151,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span <span
className={cn( className={cn(
"text-sm font-semibold leading-none", "text-sm font-medium leading-none",
day.isToday day.isToday
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center" ? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90", : "text-foreground/90",

View File

@@ -61,7 +61,7 @@ const renderLancamento = (
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span <span
className={`text-sm font-semibold leading-tight ${ className={`text-sm font-medium leading-tight ${
isPagamentoFatura && "text-success" isPagamentoFatura && "text-success"
}`} }`}
> >
@@ -74,7 +74,7 @@ const renderLancamento = (
</div> </div>
<span <span
className={cn( className={cn(
"text-sm font-semibold whitespace-nowrap", "text-sm font-medium whitespace-nowrap",
isReceita ? "text-success" : "text-foreground", isReceita ? "text-success" : "text-foreground",
)} )}
> >
@@ -103,7 +103,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight"> <span className="text-sm font-medium leading-tight">
{event.transaction.name} {event.transaction.name}
</span> </span>
@@ -116,7 +116,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge> <Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div> </div>
<span className="font-semibold"> <span className="font-medium">
<MoneyValues amount={event.transaction.amount} /> <MoneyValues amount={event.transaction.amount} />
</span> </span>
</div> </div>
@@ -129,7 +129,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight"> <span className="text-sm font-medium leading-tight">
Vencimento Invoice - {event.card.name} Vencimento Invoice - {event.card.name}
</span> </span>
</div> </div>
@@ -137,7 +137,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge> <Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
</div> </div>
{event.card.totalDue !== null ? ( {event.card.totalDue !== null ? (
<span className="font-semibold"> <span className="font-medium">
<MoneyValues amount={event.card.totalDue} /> <MoneyValues amount={event.card.totalDue} />
</span> </span>
) : null} ) : null}

View File

@@ -136,7 +136,7 @@ export function CardItem({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base"> <h3 className="truncate text-sm font-medium text-foreground sm:text-base">
{name} {name}
</h3> </h3>
{note ? ( {note ? (
@@ -188,13 +188,13 @@ export function CardItem({
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm"> <div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
<span> <span>
Fecha dia{" "} Fecha dia{" "}
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{formatDay(closingDay)} {formatDay(closingDay)}
</span> </span>
</span> </span>
<span> <span>
Vence dia{" "} Vence dia{" "}
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{formatDay(dueDay)} {formatDay(dueDay)}
</span> </span>
</span> </span>
@@ -206,7 +206,7 @@ export function CardItem({
<> <>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
<MoneyValues amount={metrics[0].value} /> <MoneyValues amount={metrics[0].value} />
</p> </p>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
@@ -215,7 +215,7 @@ export function CardItem({
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground"> <p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="size-2 rounded-full bg-primary" /> <span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} /> <MoneyValues amount={metrics[1].value} />
</p> </p>
@@ -225,7 +225,7 @@ export function CardItem({
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
<MoneyValues amount={metrics[2].value} /> <MoneyValues amount={metrics[2].value} />
</p> </p>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">

View File

@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
size="lg" size="lg"
/> />
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight"> <h1 className="text-xl font-medium leading-tight">
{category.name} {category.name}
</h1> </h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel} Total em {currentPeriodLabel}
</p> </p>
<p className="mt-1 text-2xl font-semibold"> <p className="mt-1 text-2xl font-medium">
{currencyFormatter.format(currentTotal)} {currencyFormatter.format(currentTotal)}
</p> </p>
</div> </div>
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
</p> </p>
<div <div
className={cn( className={cn(
"mt-1 flex items-center gap-1 text-xl font-semibold", "mt-1 flex items-center gap-1 text-xl font-medium",
variationColor, variationColor,
)} )}
> >

View File

@@ -97,7 +97,7 @@ export function BillPaymentDialog({
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto Boleto
</p> </p>
<p className="text-base font-semibold text-foreground"> <p className="text-base font-medium text-foreground">
{bill.name} {bill.name}
</p> </p>
</div> </div>
@@ -107,24 +107,24 @@ export function BillPaymentDialog({
<div className="rounded-xl border p-3"> <div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground"> <div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-3.5" /> <RiMoneyDollarCircleLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide"> <span className="text-xs font-medium uppercase tracking-wide">
Valor Valor
</span> </span>
</div> </div>
<MoneyValues <MoneyValues
amount={bill.amount} amount={bill.amount}
className="text-lg font-bold" className="text-lg font-medium"
/> />
</div> </div>
<div className="rounded-xl border p-3"> <div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground"> <div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" /> <RiCalendarLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide"> <span className="text-xs font-medium uppercase tracking-wide">
Vencimento Vencimento
</span> </span>
</div> </div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
{dueLabel?.replace("Vencimento: ", "") ?? "—"} {dueLabel?.replace("Vencimento: ", "") ?? "—"}
</p> </p>
</div> </div>

View File

@@ -343,10 +343,10 @@ export function CategoryBreakdownWidgetView({
<div className="rounded-lg border bg-background p-2 shadow-sm"> <div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground"> <span className="text-xs uppercase text-muted-foreground">
{entry.name} {entry.name}
</span> </span>
<span className="font-bold text-foreground"> <span className="font-medium text-foreground">
{formatCurrency(entry.value)} {formatCurrency(entry.value)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">

View File

@@ -337,7 +337,7 @@ export function DashboardGridEditable({
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center"> <div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" /> <RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold"> <span className="text-xs font-medium">
Arraste para mover Arraste para mover
</span> </span>
<Button <Button

View File

@@ -134,12 +134,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 mt-1"> <div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues <MoneyValues
className="text-[1.55rem] leading-none font-medium" className="text-2xl leading-none"
amount={metric.current} amount={metric.current}
/> />
<div <div
className={cn( className={cn(
"inline-flex items-center gap-1 text-xs font-medium", "inline-flex items-center gap-1 text-xs ",
trendBadgeClass, trendBadgeClass,
)} )}
> >
@@ -150,7 +150,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<MoneyValues <MoneyValues
className="inline text-xs font-medium text-muted-foreground" className="inline text-xs text-muted-foreground"
amount={metric.previous} amount={metric.previous}
/> />
<span className="ml-1">no mês anterior</span> <span className="ml-1">no mês anterior</span>

View File

@@ -8,7 +8,7 @@ export function DashboardWelcome({ name }: { name?: string | null }) {
return ( return (
<section className="py-4"> <section className="py-4">
<div className="tracking-tight"> <div className="tracking-tight">
<h1 className="text-xl"> <h1 className="text-xl font-medium">
{greeting}, {displayName} {greeting}, {displayName}
</h1> </h1>
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2> <h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>

View File

@@ -137,7 +137,7 @@ export function InstallmentAnalysisPage({
</p> </p>
<MoneyValues <MoneyValues
amount={grandTotal} amount={grandTotal}
className="text-3xl font-bold text-primary" className="text-3xl font-medium text-primary"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "} {selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}

View File

@@ -95,7 +95,7 @@ export function InstallmentGroupCard({
<span className="text-xs text-muted-foreground">Total:</span> <span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues <MoneyValues
amount={totalAmount} amount={totalAmount}
className="text-base font-bold" className="text-base font-medium"
/> />
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -100,7 +100,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
)} )}
</p> </p>
</div> </div>
<div className="text-sm font-semibold text-foreground"> <div className="text-sm font-medium text-foreground">
<MoneyValues amount={share.amount} /> <MoneyValues amount={share.amount} />
</div> </div>
</li> </li>

View File

@@ -46,7 +46,7 @@ export function InvoiceLogo({
) : ( ) : (
<span <span
className={cn( className={cn(
"text-sm font-semibold uppercase text-muted-foreground", "text-sm font-medium uppercase text-muted-foreground",
tone === "accent" && "text-primary", tone === "accent" && "text-primary",
fallbackClassName, fallbackClassName,
)} )}

View File

@@ -110,10 +110,10 @@ export function InvoicePaymentDialog({
fallbackClassName="text-xs" fallbackClassName="text-xs"
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide"> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Cartão Cartão
</p> </p>
<p className="truncate text-base font-semibold text-foreground"> <p className="truncate text-base font-medium text-foreground">
{invoice.cardName} {invoice.cardName}
</p> </p>
</div> </div>
@@ -124,26 +124,26 @@ export function InvoicePaymentDialog({
<div className="rounded-xl border p-3"> <div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground"> <div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-3.5" /> <RiMoneyDollarCircleLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide"> <span className="text-xs font-medium uppercase tracking-wide">
Total da fatura Total da fatura
</span> </span>
</div> </div>
<MoneyValues <MoneyValues
amount={Math.abs(invoice.totalAmount)} amount={Math.abs(invoice.totalAmount)}
className="text-lg font-bold" className="text-lg font-medium"
/> />
</div> </div>
<div className="rounded-xl border p-3"> <div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground"> <div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" /> <RiCalendarLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide"> <span className="text-xs font-medium uppercase tracking-wide">
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID {invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? "Pago em" ? "Pago em"
: "Vencimento"} : "Vencimento"}
</span> </span>
</div> </div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID {invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? (paymentInfo?.label ?? "—") ? (paymentInfo?.label ?? "—")
: (dueInfo?.label ?? "—")} : (dueInfo?.label ?? "—")}

View File

@@ -33,7 +33,7 @@ export function NoteListItem({
{getNoteTasksSummary(note)} {getNoteTasksSummary(note)}
</Badge> </Badge>
{createdAtLabel ? ( {createdAtLabel ? (
<p className="truncate text-[11px] text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{createdAtLabel} {createdAtLabel}
</p> </p>
) : null} ) : null}

View File

@@ -130,7 +130,7 @@ export function PurchasesByCategoryWidget({
<SelectContent> <SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => ( {Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}> <div key={type}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground"> <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{CATEGORY_TYPE_LABEL[ {CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type} ] ?? type}

View File

@@ -1,4 +1,4 @@
import { unstable_cache } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import { fetchDashboardAccounts } from "./accounts-queries"; import { fetchDashboardAccounts } from "./accounts-queries";
import { fetchDashboardCategoryOverview } from "./category-overview-queries"; import { fetchDashboardCategoryOverview } from "./category-overview-queries";
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries"; import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
@@ -51,18 +51,14 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
/** /**
* Cached dashboard data fetcher. * Cached dashboard data fetcher.
* Uses unstable_cache with tags for revalidation on mutations. * Uses "use cache" with tags for revalidation on mutations.
* Cache is keyed by userId + period, and invalidated via user-scoped tags. * Cache is keyed by userId + period, and invalidated via user-scoped tags.
*/ */
export function fetchDashboardData(userId: string, period: string) { export async function fetchDashboardData(userId: string, period: string) {
return unstable_cache( "use cache";
() => fetchDashboardDataInternal(userId, period), cacheTag(`dashboard-${userId}`);
[`dashboard-${userId}-${period}`], cacheLife({ revalidate: 3 });
{ return fetchDashboardDataInternal(userId, period);
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
} }
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>; export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;

View File

@@ -89,7 +89,7 @@ export async function fetchDashboardInvoices(
const paymentMap = new Map<string, string>(); const paymentMap = new Map<string, string>();
for (const row of paymentRows) { for (const row of paymentRows) {
const note = row.note; const note = row.note;
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) { if (!note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
continue; continue;
} }
const parts = note.split(":"); const parts = note.split(":");

View File

@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { unstable_cache } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import { payers } from "@/db/schema"; import { payers } from "@/db/schema";
import { fetchPendingInboxCount } from "@/features/inbox/queries"; import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
@@ -53,15 +53,9 @@ async function fetchDashboardNavbarDataInternal(
}; };
} }
export function fetchDashboardNavbarData(userId: string) { export async function fetchDashboardNavbarData(userId: string) {
const currentPeriod = getBusinessDateString().slice(0, 7); "use cache";
cacheTag(`dashboard-${userId}`);
return unstable_cache( cacheLife({ revalidate: 3 });
() => fetchDashboardNavbarDataInternal(userId), return fetchDashboardNavbarDataInternal(userId);
[`dashboard-navbar-${userId}-${currentPeriod}`],
{
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
} }

View File

@@ -1,6 +1,4 @@
"use server"; import { and, eq, gte, inArray, isNotNull, lt, ne, sql } from "drizzle-orm";
import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
import { import {
budgets, budgets,
cards, cards,
@@ -27,7 +25,11 @@ import {
toDateOnlyString, toDateOnlyString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
import { formatPeriodForUrl } from "@/shared/utils/period"; import {
addMonthsToPeriod,
formatPeriodForUrl,
getNextPeriod,
} from "@/shared/utils/period";
export type { export type {
BudgetNotification, BudgetNotification,
@@ -98,6 +100,7 @@ export async function fetchDashboardNotifications(
): Promise<DashboardNotificationsSnapshot> { ): Promise<DashboardNotificationsSnapshot> {
const today = getBusinessDateString(); const today = getBusinessDateString();
const DAYS_THRESHOLD = 5; const DAYS_THRESHOLD = 5;
const nextPeriod = getNextPeriod(currentPeriod);
const adminPayerId = await getAdminPayerId(userId); const adminPayerId = await getAdminPayerId(userId);
@@ -110,6 +113,10 @@ export async function fetchDashboardNotifications(
if (adminPayerId) { if (adminPayerId) {
boletosConditions.push(eq(transactions.payerId, adminPayerId)); boletosConditions.push(eq(transactions.payerId, adminPayerId));
} }
boletosConditions.push(isNotNull(transactions.dueDate));
boletosConditions.push(
gte(transactions.period, addMonthsToPeriod(currentPeriod, -12)),
);
const budgetJoinConditions = [ const budgetJoinConditions = [
eq(transactions.categoryId, budgets.categoryId), eq(transactions.categoryId, budgets.categoryId),
@@ -122,9 +129,58 @@ export async function fetchDashboardNotifications(
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId)); budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
} }
// --- All 4 queries are independent — run in parallel --- // Helper: monta a query de faturas por período (reutilizada para período atual e próximo)
const [overdueInvoices, currentInvoices, boletosRows, budgetRows] = const buildPeriodInvoicesQuery = (period: string) =>
await Promise.all([ db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: sql<string>`COALESCE(${invoices.period}, ${period})`,
paymentStatus: invoices.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cards)
.leftJoin(
invoices,
and(
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, period),
),
)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, period),
),
)
.where(eq(cards.userId, userId))
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
);
// --- All 5 queries are independent — run in parallel ---
const [
overdueInvoices,
currentInvoices,
nextPeriodInvoices,
boletosRows,
budgetRows,
] = await Promise.all([
// Faturas atrasadas (períodos anteriores) // Faturas atrasadas (períodos anteriores)
db db
.select({ .select({
@@ -163,48 +219,9 @@ export async function fetchDashboardNotifications(
cards.dueDay, cards.dueDay,
invoices.period, invoices.period,
), ),
// Faturas do período atual // Faturas do período atual e próximo
db buildPeriodInvoicesQuery(currentPeriod),
.select({ buildPeriodInvoicesQuery(nextPeriod),
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: sql<string>`COALESCE(${invoices.period}, ${currentPeriod})`,
paymentStatus: invoices.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cards)
.leftJoin(
invoices,
and(
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, currentPeriod),
),
)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.where(eq(cards.userId, userId))
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
),
// Boletos não pagos // Boletos não pagos
db db
.select({ .select({
@@ -229,9 +246,7 @@ export async function fetchDashboardNotifications(
.from(budgets) .from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id)) .innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(transactions, and(...budgetJoinConditions)) .leftJoin(transactions, and(...budgetJoinConditions))
.where( .where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)),
)
.groupBy(budgets.id, budgets.amount, categories.name), .groupBy(budgets.id, budgets.amount, categories.name),
]); ]);
@@ -327,6 +342,53 @@ export async function fetchDashboardNotifications(
}); });
} }
// Faturas do próximo período com vencimento próximo
const addedNotificationKeys = new Set(
notifications.map((n) => n.notificationKey),
);
for (const invoice of nextPeriodInvoices) {
if (!invoice.dueDay) continue;
const dueDate = buildDateOnlyStringFromPeriodDay(
nextPeriod,
invoice.dueDay,
);
if (!dueDate) continue;
if (invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const invoiceIsDueSoon = isDateOnlyWithinDays(
dueDate,
DAYS_THRESHOLD,
today,
);
if (!invoiceIsDueSoon) continue;
const notificationKey = buildInvoiceNotificationKey(
invoice.cardId,
nextPeriod,
);
// Evitar duplicata se já foi adicionado via currentInvoices
if (addedNotificationKeys.has(notificationKey)) continue;
const amount = toNumber(invoice.totalAmount);
notifications.push({
type: "invoice",
name: invoice.cardName,
dueDate,
status: "due_soon",
amount: Math.abs(amount),
period: nextPeriod,
showAmount: false,
cardLogo: invoice.cardLogo,
notificationKey,
fingerprint: "due_soon",
href: buildInvoiceDetailsHref(invoice.cardId, nextPeriod),
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
}
// Boletos // Boletos
for (const boleto of boletosRows) { for (const boleto of boletosRows) {
const dueDate = toDateOnlyString(boleto.dueDate); const dueDate = toDateOnlyString(boleto.dueDate);
@@ -340,6 +402,7 @@ export async function fetchDashboardNotifications(
); );
const isOldPeriod = boleto.period < currentPeriod; const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod; const isCurrentPeriod = boleto.period === currentPeriod;
const isNextPeriod = boleto.period === nextPeriod;
const amount = toNumber(boleto.amount); const amount = toNumber(boleto.amount);
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`; const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
const notificationKey = buildBoletoNotificationKey(boleto.id); const notificationKey = buildBoletoNotificationKey(boleto.id);
@@ -380,6 +443,23 @@ export async function fetchDashboardNotifications(
readAt: null, readAt: null,
archivedAt: null, archivedAt: null,
}); });
} else if (isNextPeriod && boletoIsDueSoon) {
notifications.push({
type: "boleto",
name: boleto.name,
dueDate,
status: "due_soon",
amount: Math.abs(amount),
period: boleto.period,
showAmount: false,
notificationKey,
fingerprint: "due_soon",
href,
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
} }
} }

View File

@@ -1,4 +1,4 @@
import { unstable_cache } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data"; import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries"; import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
import { import {
@@ -52,15 +52,11 @@ async function fetchDashboardQuickActionOptionsInternal(
}; };
} }
export function fetchDashboardQuickActionOptions(userId: string) { export async function fetchDashboardQuickActionOptions(userId: string) {
return unstable_cache( "use cache";
() => fetchDashboardQuickActionOptionsInternal(userId), cacheTag(`dashboard-${userId}`);
[`dashboard-quick-actions-${userId}`], cacheLife({ revalidate: 3 });
{ return fetchDashboardQuickActionOptionsInternal(userId);
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
} }
export async function fetchDashboardPageData(userId: string, period: string) { export async function fetchDashboardPageData(userId: string, period: string) {

View File

@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions"; import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
import { db, schema } from "@/shared/lib/db"; import { db, schema } from "@/shared/lib/db";
@@ -9,6 +10,10 @@ export interface UserDashboardPreferences {
export async function fetchUserDashboardPreferences( export async function fetchUserDashboardPreferences(
userId: string, userId: string,
): Promise<UserDashboardPreferences> { ): Promise<UserDashboardPreferences> {
"use cache";
cacheTag(`dashboard-${userId}`);
cacheLife({ revalidate: 3 });
const result = await db const result = await db
.select({ .select({
dashboardWidgets: schema.userPreferences.dashboardWidgets, dashboardWidgets: schema.userPreferences.dashboardWidgets,

View File

@@ -144,7 +144,7 @@ export const InboxCard = memo(function InboxCard({
<CardContent className="flex-1 py-2"> <CardContent className="flex-1 py-2">
{item.originalTitle && ( {item.originalTitle && (
<p className="mb-1 text-sm font-bold">{item.originalTitle}</p> <p className="mb-1 text-sm font-medium">{item.originalTitle}</p>
)} )}
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground"> <p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
{item.originalText} {item.originalText}

View File

@@ -1,6 +1,6 @@
import { getDay } from "date-fns"; import { getDay } from "date-fns";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { unstable_cache } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import { import {
budgets, budgets,
cards, cards,
@@ -481,13 +481,9 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
}; };
} }
export function aggregateMonthData(userId: string, period: string) { export async function aggregateMonthData(userId: string, period: string) {
return unstable_cache( "use cache";
() => aggregateMonthDataInternal(userId, period), cacheTag(`dashboard-${userId}`);
[`insights-aggregate-${userId}-${period}`], cacheLife({ revalidate: 3 });
{ return aggregateMonthDataInternal(userId, period);
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
} }

View File

@@ -82,7 +82,7 @@ export function InsightsGrid({ insights }: InsightsGridProps) {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className={cn("size-5", colors.chatAiIcon)} /> <Icon className={cn("size-5", colors.chatAiIcon)} />
<CardTitle className={cn("font-semibold", colors.titleText)}> <CardTitle className={cn("font-medium", colors.titleText)}>
{categoryConfig.title} {categoryConfig.title}
</CardTitle> </CardTitle>
</div> </div>

View File

@@ -267,7 +267,7 @@ function ErrorState({
return ( return (
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center"> <div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold text-destructive"> <h3 className="text-lg font-medium text-destructive">
Erro ao gerar insights Erro ao gerar insights
</h3> </h3>
<p className="text-sm text-muted-foreground max-w-md">{error}</p> <p className="text-sm text-muted-foreground max-w-md">{error}</p>

View File

@@ -133,7 +133,7 @@ export function ModelSelector({
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6"> <Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
{/* Descrição */} {/* Descrição */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-semibold">Definir modelo de análise</h3> <h3 className="text-lg font-medium">Definir modelo de análise</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Escolha o provedor de IA e o modelo específico que será utilizado para Escolha o provedor de IA e o modelo específico que será utilizado para
gerar insights sobre seus dados financeiros. <br /> gerar insights sobre seus dados financeiros. <br />

View File

@@ -171,12 +171,12 @@ export function InvoiceSummaryCard({
/> />
</div> </div>
) : cardBrand ? ( ) : cardBrand ? (
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-semibold text-muted-foreground"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-medium text-muted-foreground">
{cardBrand.slice(0, 2).toUpperCase()} {cardBrand.slice(0, 2).toUpperCase()}
</span> </span>
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-sm font-medium text-foreground">
{cardName} {cardName}
</h2> </h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -195,14 +195,14 @@ export function InvoiceSummaryCard({
<MoneyValues <MoneyValues
amount={totalAmount} amount={totalAmount}
className={cn( className={cn(
"text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]", "text-3xl leading-none font-medium tracking-tight sm:text-[2rem]",
isPaid ? "text-success" : "text-foreground", isPaid ? "text-success" : "text-foreground",
)} )}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]} variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
className="text-[11px]" className="text-xs"
> >
{INVOICE_STATUS_LABEL[invoiceStatus]} {INVOICE_STATUS_LABEL[invoiceStatus]}
</Badge> </Badge>
@@ -218,20 +218,20 @@ export function InvoiceSummaryCard({
{/* Linha 3 — metadados do cartão */} {/* Linha 3 — metadados do cartão */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<MetaItem label="Vencimento"> <MetaItem label="Vencimento">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-medium text-foreground">
Dia {formatDay(dueDay)} Dia {formatDay(dueDay)}
</span> </span>
</MetaItem> </MetaItem>
<MetaItem label="Fechamento"> <MetaItem label="Fechamento">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-medium text-foreground">
Dia {formatDay(closingDay)} Dia {formatDay(closingDay)}
</span> </span>
</MetaItem> </MetaItem>
{typeof limitAmount === "number" ? ( {typeof limitAmount === "number" ? (
<MetaItem label="Limite"> <MetaItem label="Limite">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-medium text-foreground">
{formatCurrency(limitAmount)} {formatCurrency(limitAmount)}
</span> </span>
</MetaItem> </MetaItem>
@@ -249,7 +249,7 @@ export function InvoiceSummaryCard({
className="h-4 w-auto shrink-0" className="h-4 w-auto shrink-0"
/> />
) : null} ) : null}
<span className="text-sm font-semibold text-foreground truncate"> <span className="text-sm font-medium text-foreground truncate">
{cardBrand} {cardBrand}
</span> </span>
</div> </div>

View File

@@ -99,11 +99,11 @@ function StepCard({
<Card className="border"> <Card className="border">
<CardContent> <CardContent>
<div className="flex gap-3 md:gap-4"> <div className="flex gap-3 md:gap-4">
<div className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-bold text-sm md:text-base"> <div className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-medium text-sm md:text-base">
{step} {step}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="font-semibold mb-1.5 md:mb-2">{title}</h3> <h3 className="font-medium mb-1.5 md:mb-2">{title}</h3>
{children} {children}
</div> </div>
</div> </div>

View File

@@ -77,7 +77,7 @@ export function NoteCard({
<CardContent className="flex min-h-0 flex-1 flex-col gap-4"> <CardContent className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex shrink-0 items-start justify-between gap-3"> <div className="flex shrink-0 items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word"> <h3 className="text-lg font-medium leading-tight text-foreground wrap-break-word">
{displayTitle} {displayTitle}
</h3> </h3>
{createdAtLabel && ( {createdAtLabel && (

View File

@@ -43,7 +43,7 @@ export function PayerCardUsageCard({ items }: PagadorCardUsageCardProps) {
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
) : ( ) : (
<span className="text-sm font-semibold uppercase text-muted-foreground"> <span className="text-sm font-medium uppercase text-muted-foreground">
{initials} {initials}
</span> </span>
)} )}

View File

@@ -118,7 +118,7 @@ export function PayerHeaderCard({
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl font-semibold text-foreground"> <CardTitle className="text-xl font-medium text-foreground">
{payer.name} {payer.name}
</CardTitle> </CardTitle>
{isAdmin ? ( {isAdmin ? (
@@ -197,11 +197,11 @@ export function PayerHeaderCard({
<DialogTitle>Confirmar envio do resumo</DialogTitle> <DialogTitle>Confirmar envio do resumo</DialogTitle>
<DialogDescription> <DialogDescription>
Resumo de{" "} Resumo de{" "}
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{summary.periodLabel} {summary.periodLabel}
</span>{" "} </span>{" "}
para{" "} para{" "}
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{payer.email} {payer.email}
</span> </span>
</DialogDescription> </DialogDescription>
@@ -218,7 +218,7 @@ export function PayerHeaderCard({
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">
Total de Despesas Total de Despesas
</p> </p>
<p className="text-2xl font-bold text-foreground"> <p className="text-2xl font-medium text-foreground">
{formatCurrency(summary.totalExpenses)} {formatCurrency(summary.totalExpenses)}
</p> </p>
</div> </div>
@@ -235,11 +235,11 @@ export function PayerHeaderCard({
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground"> <div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiBankCard2Line className="size-4" /> <RiBankCard2Line className="size-4" />
<span className="text-xs font-semibold uppercase"> <span className="text-xs font-medium uppercase">
Cartões Cartões
</span> </span>
</div> </div>
<p className="text-lg font-bold text-foreground"> <p className="text-lg font-medium text-foreground">
{formatCurrency(summary.paymentSplits.card)} {formatCurrency(summary.paymentSplits.card)}
</p> </p>
</div> </div>
@@ -247,11 +247,11 @@ export function PayerHeaderCard({
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground"> <div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiBillLine className="size-4" /> <RiBillLine className="size-4" />
<span className="text-xs font-semibold uppercase"> <span className="text-xs font-medium uppercase">
Boletos Boletos
</span> </span>
</div> </div>
<p className="text-lg font-bold text-foreground"> <p className="text-lg font-medium text-foreground">
{formatCurrency(summary.paymentSplits.boleto)} {formatCurrency(summary.paymentSplits.boleto)}
</p> </p>
</div> </div>
@@ -259,11 +259,11 @@ export function PayerHeaderCard({
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground"> <div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiExchangeDollarLine className="size-4" /> <RiExchangeDollarLine className="size-4" />
<span className="text-xs font-semibold uppercase"> <span className="text-xs font-medium uppercase">
Pix/Débito Pix/Débito
</span> </span>
</div> </div>
<p className="text-lg font-bold text-foreground"> <p className="text-lg font-medium text-foreground">
{formatCurrency(summary.paymentSplits.instant)} {formatCurrency(summary.paymentSplits.instant)}
</p> </p>
</div> </div>
@@ -274,7 +274,7 @@ export function PayerHeaderCard({
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<RiBankCard2Line className="size-4 text-muted-foreground" /> <RiBankCard2Line className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground"> <span className="text-xs font-medium uppercase text-muted-foreground">
Cartões Utilizados Cartões Utilizados
</span> </span>
</div> </div>
@@ -299,14 +299,14 @@ export function PayerHeaderCard({
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<RiBillLine className="size-4 text-muted-foreground" /> <RiBillLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground"> <span className="text-xs font-medium uppercase text-muted-foreground">
Status de Boletos Status de Boletos
</span> </span>
</div> </div>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<div> <div>
<p className="text-xs text-muted-foreground">Pagos</p> <p className="text-xs text-muted-foreground">Pagos</p>
<p className="text-sm font-semibold text-success"> <p className="text-sm font-medium text-success">
{formatCurrency(summary.boletoStats.paidAmount)}{" "} {formatCurrency(summary.boletoStats.paidAmount)}{" "}
<span className="text-xs font-normal"> <span className="text-xs font-normal">
({summary.boletoStats.paidCount}) ({summary.boletoStats.paidCount})
@@ -317,7 +317,7 @@ export function PayerHeaderCard({
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Pendentes Pendentes
</p> </p>
<p className="text-sm font-semibold text-warning"> <p className="text-sm font-medium text-warning">
{formatCurrency(summary.boletoStats.pendingAmount)}{" "} {formatCurrency(summary.boletoStats.pendingAmount)}{" "}
<span className="text-xs font-normal"> <span className="text-xs font-normal">
({summary.boletoStats.pendingCount}) ({summary.boletoStats.pendingCount})

View File

@@ -50,7 +50,7 @@ const ValueLabel = (props: LabelProps) => {
y={labelY} y={labelY}
fill="currentColor" fill="currentColor"
textAnchor="middle" textAnchor="middle"
className="text-[11px] font-semibold text-muted-foreground" className="text-xs font-medium text-muted-foreground"
> >
{amount} {amount}
</text> </text>
@@ -63,7 +63,7 @@ export function PayerHistoryCard({ data }: PagadorHistoryCardProps) {
return ( return (
<Card className="border"> <Card className="border">
<CardHeader className="gap-1.5 pb-3"> <CardHeader className="gap-1.5 pb-3">
<CardTitle className="text-lg font-semibold"> <CardTitle className="text-lg font-medium">
Evolução (últimos 6 meses) Evolução (últimos 6 meses)
</CardTitle> </CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -31,7 +31,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
return ( return (
<Card className="border gap-4"> <Card className="border gap-4">
<CardHeader className="gap-1.5"> <CardHeader className="gap-1.5">
<CardTitle className="text-lg font-semibold"> <CardTitle className="text-lg font-medium">
Detalhes do pagador Detalhes do pagador
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -79,7 +79,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
<InfoItem <InfoItem
label="Aviso" label="Aviso"
value={ value={
<span className="text-[13px] text-warning"> <span className="text-sm text-warning">
Cadastre um e-mail para permitir o envio automático. Cadastre um e-mail para permitir o envio automático.
</span> </span>
} }
@@ -118,7 +118,7 @@ type InfoItemProps = {
function InfoItem({ label, value, className }: InfoItemProps) { function InfoItem({ label, value, className }: InfoItemProps) {
return ( return (
<div className={cn("space-y-1", className)}> <div className={cn("space-y-1", className)}>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80"> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground/80">
{label} {label}
</span> </span>
<div className="text-base text-foreground">{value}</div> <div className="text-base text-foreground">{value}</div>

View File

@@ -53,7 +53,7 @@ export function PayerLeaveShareCard({
return ( return (
<Card className="border"> <Card className="border">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold"> <CardTitle className="text-base font-medium">
Acesso Compartilhado Acesso Compartilhado
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -63,7 +63,7 @@ export function PayerLeaveShareCard({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm"> <div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground/80"> <span className="text-xs font-medium uppercase text-muted-foreground/80">
Informações do compartilhamento Informações do compartilhamento
</span> </span>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View File

@@ -51,7 +51,7 @@ export function PayerMonthlySummaryCard({
return ( return (
<Card> <Card>
<CardHeader className="flex flex-col gap-1.5"> <CardHeader className="flex flex-col gap-1.5">
<CardTitle className="text-lg font-semibold">Totais do mês</CardTitle> <CardTitle className="text-lg font-medium">Totais do mês</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{periodLabel} - Despesas por forma de pagamento {periodLabel} - Despesas por forma de pagamento
</p> </p>
@@ -65,7 +65,7 @@ export function PayerMonthlySummaryCard({
</span> </span>
<MoneyValues <MoneyValues
amount={breakdown.totalExpenses} amount={breakdown.totalExpenses}
className="block text-2xl font-semibold text-foreground" className="block text-2xl font-medium text-foreground"
/> />
</div> </div>
@@ -100,7 +100,7 @@ export function PayerMonthlySummaryCard({
totalBase > 0 ? Math.round((entry.value / totalBase) * 100) : 0; totalBase > 0 ? Math.round((entry.value / totalBase) * 100) : 0;
return ( return (
<div key={entry.key} className="space-y-1 rounded-lg border p-3"> <div key={entry.key} className="space-y-1 rounded-lg border p-3">
<span className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground/70"> <span className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
<span <span
className={cn("size-2 rounded-full", entry.color)} className={cn("size-2 rounded-full", entry.color)}
aria-hidden aria-hidden
@@ -109,7 +109,7 @@ export function PayerMonthlySummaryCard({
</span> </span>
<MoneyValues <MoneyValues
amount={entry.value} amount={entry.value}
className="block text-lg font-semibold text-foreground" className="block text-lg font-medium text-foreground"
/> />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{percent}% das despesas {percent}% das despesas

View File

@@ -84,9 +84,7 @@ export function PayerSharingCard({
return ( return (
<Card className="border"> <Card className="border">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-semibold"> <CardTitle className="text-lg font-medium">Compartilhamentos</CardTitle>
Compartilhamentos
</CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Compartilhe o código abaixo com outra pessoa. Ela poderá adicioná-lo Compartilhe o código abaixo com outra pessoa. Ela poderá adicioná-lo
na página de pagadores usando a opção Adicionar por código para ter na página de pagadores usando a opção Adicionar por código para ter
@@ -95,7 +93,7 @@ export function PayerSharingCard({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm"> <div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground/80"> <span className="text-xs font-medium uppercase text-muted-foreground/80">
Código de compartilhamento Código de compartilhamento
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@@ -42,7 +42,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
{/* Nome e badges */} {/* Nome e badges */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<h3 className="text-base font-semibold text-foreground"> <h3 className="text-base font-medium text-foreground">
{payer.name} {payer.name}
</h3> </h3>
{isAdmin ? ( {isAdmin ? (

View File

@@ -61,7 +61,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{/* Rank number */} {/* Rank number */}
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted"> <div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted">
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{index + 1} {index + 1}
</span> </span>
</div> </div>

View File

@@ -67,11 +67,11 @@ export function CardsOverview({ data }: CardsOverviewProps) {
<p className="text-xs text-muted-foreground">{card.title}</p> <p className="text-xs text-muted-foreground">{card.title}</p>
{card.isMoney ? ( {card.isMoney ? (
<MoneyValues <MoneyValues
className="text-2xl font-semibold" className="text-2xl font-medium"
amount={card.value} amount={card.value}
/> />
) : ( ) : (
<p className="text-2xl font-semibold"> <p className="text-2xl font-medium">
{formatPercentage(card.value, { {formatPercentage(card.value, {
maximumFractionDigits: 0, maximumFractionDigits: 0,
minimumFractionDigits: 0, minimumFractionDigits: 0,
@@ -83,7 +83,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
))} ))}
</div> </div>
<p className="text-base font-bold ml-2 py-2">Meus cartões</p> <p className="text-base font-medium ml-2 py-2">Meus cartões</p>
{/* Cards list */} {/* Cards list */}
<div className="grid gap-2 grid-cols-2 lg:grid-cols-4 xl:grid-cols-4"> <div className="grid gap-2 grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
@@ -116,7 +116,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
</div> </div>
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-base font-bold truncate"> <span className="text-base font-medium truncate">
{card.name} {card.name}
</span> </span>
{brandAsset && ( {brandAsset && (

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