32 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
Felipe Coutinho
20d0c3e0a7 chore(docs): atualizar regra de versionamento no CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:56 +00:00
Felipe Coutinho
71b5a004e3 chore: ajustes de formatação e configuração
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:27 +00:00
Felipe Coutinho
65b1506d75 chore(release): publicar versão 2.1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:23 +00:00
Felipe Coutinho
2a458d5a3c chore(configurações): redesign visual da página de configurações
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:19 +00:00
Felipe Coutinho
f418987f47 feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:33 +00:00
Felipe Coutinho
59b4dea071 feat(preferências): configuração de tamanho máximo de anexo por arquivo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:28 +00:00
Felipe Coutinho
6ce132fe0c feat(db): adicionar coluna attachmentMaxSizeMb em userPreferences
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:45:41 +00:00
Felipe Coutinho
49731238e4 Update version badge from 2.1.0 to 2.1.1 2026-03-29 11:14:23 -03:00
Felipe Coutinho
c5df97f7aa chore(setup): reduzir logo e colocar nome ASCII lado a lado
Logo reduzido de 19 para 10 linhas (seleção dos frames-chave).
Nome do projeto em ASCII art posicionado ao lado direito do logo,
centralizado verticalmente. Tagline abaixo do bloco.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:05:53 +00:00
Felipe Coutinho
3476fda4db chore(setup): adicionar banner ASCII do logo e corrigir script db:extensions
Substitui o header simples pelo logo em ASCII art na cor primária
(laranja) com nome e tagline centralizados. Corrige chamada
db:enableExtensions → db:extensions após renomeio do script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:55:26 +00:00
Felipe Coutinho
519b673ae5 chore(release): publicar versão 2.1.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:21 +00:00
Felipe Coutinho
303b8bedd4 chore(config): limpeza de tsconfig.json e .vscode/settings.json
Reformata arrays no tsconfig para multi-line. Remove configurações
obsoletas do .vscode (explorerExclude.backup, eslint.enable,
typescript.preferences.organizeImportsCollation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:17 +00:00
Felipe Coutinho
f2b9b16896 chore(package): renomear scripts e remover dependências Vercel
Renomeia mockup→db:seed, db:enableExtensions→db:extensions e remove
o script dev-env. Remove @vercel/analytics e @vercel/speed-insights.
Atualiza README com o novo nome do script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:13 +00:00
Felipe Coutinho
6eba35542b chore(logo): remover prop showVersion e atualizar logo_small.png
Remove a prop showVersion do componente Logo e seu uso na sidebar.
Aplica iconFilterClass também no variant compact. Atualiza a imagem
logo_small.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:10 +00:00
Felipe Coutinho
f5e95ffba6 chore(analytics): substituir Vercel Analytics por Umami self-hosted
Remove @vercel/analytics e @vercel/speed-insights e adiciona o script
do Umami self-hosted no layout raiz, restrito ao domínio openmonetis.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:03 +00:00
Felipe Coutinho
a75bb86eec refactor(navbar): extrair NavbarShell e adicionar variante navbar no Button
Unifica a estrutura da navbar entre o app e a landing page via novo
componente NavbarShell. Centraliza estilos de botões da navbar na
variante `navbar` do Button, eliminando nav-styles.ts e as classes
inline duplicadas. AnimatedThemeToggler, RefreshPageButton e MobileNav
passam a aceitar prop `variant` para adaptar ao contexto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:43:59 +00:00
Felipe Coutinho
a3b858621f fix(transactions): preservar período salvo ao editar lançamento de cartão
No modal de edição, o período não era recalculado com base no fechamento
do cartão, garantindo que o valor salvo no banco seja sempre exibido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:51:43 +00:00
181 changed files with 5548 additions and 971 deletions

View File

@@ -12,7 +12,6 @@
"**/.next": true, "**/.next": true,
".next": true ".next": true
}, },
"explorerExclude.backup": {},
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
@@ -25,9 +24,7 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"eslint.enable": false,
"prettier.enable": false, "prettier.enable": false,
"typescript.preferences.organizeImportsCollation": "ordinal",
"editor.fontSize": 15, "editor.fontSize": 15,
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features" "editor.defaultFormatter": "vscode.json-language-features"

View File

@@ -7,6 +7,69 @@ 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
### Adicionado
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
### Corrigido
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
### Alterado
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
## [2.1.1] - 2026-03-29
### Adicionado
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
- Analytics: integração com Umami self-hosted via script tag no layout raiz
### Alterado
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
### Removido
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
## [2.1.0] - 2026-03-28 ## [2.1.0] - 2026-03-28
### Adicionado ### Adicionado

View File

@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog. 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema. 8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
@@ -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.0-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/)
@@ -156,7 +156,7 @@ O script irá:
```bash ```bash
docker compose up db -d docker compose up db -d
pnpm db:enableExtensions pnpm db:extensions
``` ```
4. **Execute as migrations e inicie** 4. **Execute as migrations e inicie**

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

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1774529878374, "when": 1774529878374,
"tag": "0023_sturdy_wolfpack", "tag": "0023_sturdy_wolfpack",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
} }
] ]
} }

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,11 +1,10 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.1.0", "version": "2.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"dev-env": "tsx scripts/dev.ts", "db:seed": "tsx scripts/mock-data.ts",
"mockup": "tsx scripts/mock-data.ts",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check .", "lint": "biome check .",
@@ -14,9 +13,10 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:enableExtensions": "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",
@@ -61,8 +61,6 @@
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.141", "ai": "^6.0.141",
"better-auth": "1.5.6", "better-auth": "1.5.6",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@@ -75,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",

212
pnpm-lock.yaml generated
View File

@@ -104,12 +104,6 @@ importers:
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.13.23 specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@vercel/speed-insights':
specifier: ^2.0.0
version: 2.0.0(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
ai: ai:
specifier: ^6.0.141 specifier: ^6.0.141
version: 6.0.141(zod@4.3.6) version: 6.0.141(zod@4.3.6)
@@ -146,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
@@ -1297,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==}
@@ -2666,65 +2738,10 @@ packages:
'@types/whatwg-url@13.0.0': '@types/whatwg-url@13.0.0':
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
'@vercel/analytics@2.0.1':
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
peerDependencies:
'@remix-run/react': ^2
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
nuxt: '>= 3'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@remix-run/react':
optional: true
'@sveltejs/kit':
optional: true
next:
optional: true
nuxt:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
'@vercel/oidc@3.1.0': '@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
'@vercel/speed-insights@2.0.0':
resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==}
peerDependencies:
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
nuxt: '>= 3'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
next:
optional: true
nuxt:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
adler-32@1.3.1: adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -3427,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'}
@@ -3449,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==}
@@ -4997,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':
@@ -6557,18 +6629,8 @@ snapshots:
'@types/webidl-conversions': 7.0.3 '@types/webidl-conversions': 7.0.3
optional: true optional: true
'@vercel/analytics@2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
optionalDependencies:
next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
'@vercel/oidc@3.1.0': {} '@vercel/oidc@3.1.0': {}
'@vercel/speed-insights@2.0.0(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
optionalDependencies:
next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
adler-32@1.3.1: {} adler-32@1.3.1: {}
ai@6.0.141(zod@4.3.6): ai@6.0.141(zod@4.3.6):
@@ -7212,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
@@ -7232,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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

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

@@ -21,6 +21,7 @@ const c = {
red: "\x1b[31m", red: "\x1b[31m",
yellow: "\x1b[33m", yellow: "\x1b[33m",
cyan: "\x1b[36m", cyan: "\x1b[36m",
orange: "\x1b[38;5;214m",
}; };
const sym = { const sym = {
@@ -81,10 +82,38 @@ function abort(msg) {
// ─── Header ────────────────────────────────────────────────────────────────── // ─── Header ──────────────────────────────────────────────────────────────────
console.log(` const logoLines = [
${c.bold}${c.cyan} OpenMonetis — Setup${c.reset} ".............................+@@@@@@@@@@=.............................",
${c.dim}Gestão financeira self-hosted${c.reset} ".............................@@@@@@@@@@@:.............................",
`); "...................+@@@@@@*-:@@@@@@@@@@%...=@@@@@@-...................",
"..................@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%..................",
"................=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+................",
"...................-=+%@@@@@@@@@@@@@@@@@@@@@*:........................",
".......................#@@@@@@@@@@@@@@@@@@@@@@@+......................",
"....................%@@@@@@@@@@@@@%#@@@@@@@@@@@@*.....................",
"....................+@@@@@@@@@@@......*@@@@@@#........................",
".........................:#@@=...........+#...........................",
];
const nameLines = [
" ___ __ __ _ _ ",
" / _ \\ _ __ ___ _ __ | \\/ | ___ _ __ ___| |_(_)___ ",
" | | | | '_ \\ / _ \\ '_ \\| |\\/| |/ _ \\| '_ \\ / _ \\ __| / __|",
" | |_| | |_) | __/ | | | | | | (_) | | | | __/ |_| \\__ \\",
" \\___/| .__/ \\___|_| |_|_| |_|\\___/|_| |_|\\___|\\__|_|___/",
" |_| ",
];
const nameStart = Math.floor((logoLines.length - nameLines.length) / 2);
console.log();
for (let i = 0; i < logoLines.length; i++) {
const logoCol = c.orange + logoLines[i].replaceAll(".", " ").substring(14, 56).padEnd(42) + c.reset;
const nameIdx = i - nameStart;
const nameCol = nameIdx >= 0 && nameIdx < nameLines.length ? nameLines[nameIdx] : "";
console.log(logoCol + " " + nameCol);
}
console.log(`\n${" ".repeat(46)}${c.dim}Gestão financeira · self-hosted${c.reset}\n`);
// ─── ETAPA 1: Verificações do sistema ──────────────────────────────────────── // ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
@@ -329,7 +358,7 @@ if (useLocalDocker) {
// Extensões // Extensões
s = spinner("Habilitando extensões do banco..."); s = spinner("Habilitando extensões do banco...");
try { try {
run("pnpm db:enableExtensions", { cwd: targetDir }); run("pnpm db:extensions", { cwd: targetDir });
s.stop("Extensões habilitadas"); s.stop("Extensões habilitadas");
} catch { } catch {
s.fail("Falha ao habilitar extensões"); s.fail("Falha ao habilitar extensões");

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;
@@ -190,6 +192,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={false} allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</section> </section>
</main> </main>

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;
@@ -202,6 +204,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id} defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito" defaultPaymentMethod="Cartão de crédito"
lockCardSelection lockCardSelection

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;
@@ -99,6 +101,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true} allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

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;
@@ -390,6 +392,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}

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 { 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";
@@ -11,6 +12,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form"; import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
import { fetchSettingsPageData } from "@/features/settings/queries"; import { fetchSettingsPageData } from "@/features/settings/queries";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -20,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(),
}); });
@@ -64,12 +67,13 @@ 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-lg 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 mb-4"> <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.
</p> </p>
</div> </div>
<Separator />
<PreferencesForm <PreferencesForm
statementNoteAsColumn={ statementNoteAsColumn={
userPreferences?.statementNoteAsColumn ?? false userPreferences?.statementNoteAsColumn ?? false
@@ -77,25 +81,46 @@ export default async function Page() {
transactionsColumnOrder={ transactionsColumnOrder={
userPreferences?.transactionsColumnOrder ?? null userPreferences?.transactionsColumnOrder ?? null
} }
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</div> </div>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="companion" className="mt-4"> <TabsContent value="companion" className="mt-4">
<CompanionTab tokens={userApiTokens} /> <Card className="p-6">
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<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">
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
caixa de entrada.
</p>
</div>
<Separator />
<CompanionTab tokens={userApiTokens} />
</div>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="nome" className="mt-4"> <TabsContent value="nome" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg 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 mb-4"> <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.
</p> </p>
</div> </div>
<Separator />
<UpdateNameForm currentName={userName} /> <UpdateNameForm currentName={userName} />
</div> </div>
</Card> </Card>
@@ -105,12 +130,13 @@ 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-lg 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 mb-4"> <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.
</p> </p>
</div> </div>
<Separator />
<UpdatePasswordForm authProvider={authProvider} /> <UpdatePasswordForm authProvider={authProvider} />
</div> </div>
</Card> </Card>
@@ -120,12 +146,13 @@ 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-lg font-bold mb-1">Passkeys</h2> <h2 className="text-xl font-medium mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4"> <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.
</p> </p>
</div> </div>
<Separator />
<PasskeysForm /> <PasskeysForm />
</div> </div>
</Card> </Card>
@@ -135,13 +162,14 @@ 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-lg 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 mb-4"> <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
atual (quando aplicável) para concluir a alteração. atual (quando aplicável) para concluir a alteração.
</p> </p>
</div> </div>
<Separator />
<UpdateEmailForm <UpdateEmailForm
currentEmail={userEmail} currentEmail={userEmail}
authProvider={authProvider} authProvider={authProvider}
@@ -154,14 +182,15 @@ 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-lg 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 mb-4"> <p className="text-sm text-muted-foreground">
Você pode zerar os dados do OpenMonetis e manter seu acesso, Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível. ou excluir sua conta inteira de forma irreversível.
</p> </p>
</div> </div>
<Separator />
<DeleteAccountForm /> <DeleteAccountForm />
</div> </div>
</Card> </Card>

View File

@@ -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;
@@ -102,6 +104,7 @@ export default async function Page({ searchParams }: PageProps) {
}} }}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -17,7 +17,6 @@ import {
extraFeatures, extraFeatures,
getMetricsItems, getMetricsItems,
mainFeatures, mainFeatures,
navbarActionClassName,
navLinks, navLinks,
pwaHighlights, pwaHighlights,
stackItems, stackItems,
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries"; import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo"; import { Logo } from "@/shared/components/logo";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
@@ -50,65 +50,60 @@ export default async function Page() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
{/* Navigation */} {/* Navigation */}
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary"> <NavbarShell>
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between"> {/* Center Navigation Links */}
<Logo variant="compact" invertTextOnDark={false} /> <nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
{navLinks.map(({ href, label }) => (
<a
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
>
{label}
</a>
))}
</nav>
{/* Center Navigation Links */} <nav className="ml-auto flex items-center gap-2 md:gap-3">
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2"> <AnimatedThemeToggler variant="navbar" />
{navLinks.map(({ href, label }) => ( {!isPublicDomain &&
<a (session?.user ? (
key={href} <Link prefetch href="/dashboard" className="hidden md:block">
href={href} <Button
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors" variant="outline"
> size="sm"
{label} className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
</a> >
))} Dashboard
</nav> </Button>
</Link>
<nav className="flex items-center gap-2 md:gap-3"> ) : (
<AnimatedThemeToggler className={navbarActionClassName} /> <div className="hidden md:flex items-center gap-2">
{!isPublicDomain && <Link href="/login">
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none" className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
> >
Dashboard Entrar
</Button> </Button>
</Link> </Link>
) : ( <Link href="/signup">
<div className="hidden md:flex items-center gap-2"> <Button
<Link href="/login"> size="sm"
<Button className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
variant="ghost" >
size="sm" Começar
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none" </Button>
> </Link>
Entrar </div>
</Button> ))}
</Link> <MobileNav
<Link href="/signup"> isPublicDomain={isPublicDomain}
<Button isLoggedIn={!!session?.user}
size="sm" />
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2" </nav>
> </NavbarShell>
Começar
</Button>
</Link>
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
/>
</nav>
</div>
</header>
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0"> <section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
@@ -131,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>
@@ -212,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">
@@ -234,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">
@@ -259,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">
@@ -287,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">
@@ -351,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">
@@ -389,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">
@@ -435,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">
@@ -446,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,
@@ -534,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">
@@ -561,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">
@@ -587,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">
@@ -622,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">
@@ -649,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>
@@ -669,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">
@@ -720,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
@@ -754,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,6 +1,5 @@
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
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";
@@ -28,14 +27,18 @@ export default function RootLayout({
> >
<head> <head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" /> <meta name="apple-mobile-web-app-title" content="OpenMonetis" />
<script
defer
src="https://umami.felipecoutinho.com/script.js"
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
data-domains="openmonetis.com"
/>
</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>
<Analytics />
<SpeedInsights />
</body> </body>
</html> </html>
); );

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

@@ -135,6 +135,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
transactionsColumnOrder: jsonb("lancamentos_column_order").$type< transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null string[] | null
>(), >(),
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
dashboardWidgets: jsonb("dashboard_widgets").$type<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];

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

@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
}); });
if (passkeyError) { if (passkeyError) {
setError((passkeyError.message as string) || "Erro ao entrar com passkey."); setError(
(passkeyError.message as string) || "Erro ao entrar com passkey.",
);
setLoadingPasskey(false); setLoadingPasskey(false);
} }
} }
@@ -238,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,118 +129,126 @@ 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
// Faturas atrasadas (períodos anteriores) .select({
db invoiceId: invoices.id,
.select({ cardId: cards.id,
invoiceId: invoices.id, cardName: cards.name,
cardId: cards.id, cardLogo: cards.logo,
cardName: cards.name, dueDay: cards.dueDay,
cardLogo: cards.logo, period: sql<string>`COALESCE(${invoices.period}, ${period})`,
dueDay: cards.dueDay, paymentStatus: invoices.paymentStatus,
period: invoices.period, totalAmount: sql<number | null>`
totalAmount: sql<
number | null
>`COALESCE(SUM(${transactions.amount}), 0)`,
})
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.leftJoin(
transactions,
and(
eq(transactions.cardId, invoices.cardId),
eq(transactions.period, invoices.period),
eq(transactions.userId, invoices.userId),
),
)
.where(
and(
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
)
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
),
// Faturas do período atual
db
.select({
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) COALESCE(SUM(${transactions.amount}), 0)
`, `,
transactionCount: sql<number | null>`COUNT(${transactions.id})`, transactionCount: sql<number | null>`COUNT(${transactions.id})`,
}) })
.from(cards) .from(cards)
.leftJoin( .leftJoin(
invoices, invoices,
and( and(
eq(invoices.cardId, cards.id), eq(invoices.cardId, cards.id),
eq(invoices.userId, userId), eq(invoices.userId, userId),
eq(invoices.period, currentPeriod), eq(invoices.period, period),
),
)
.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 )
db .leftJoin(
.select({ transactions,
id: transactions.id, and(
name: transactions.name, eq(transactions.cardId, cards.id),
amount: transactions.amount, eq(transactions.userId, userId),
dueDate: transactions.dueDate, eq(transactions.period, period),
period: transactions.period, ),
}) )
.from(transactions) .where(eq(cards.userId, userId))
.where(and(...boletosConditions)), .groupBy(
// Orçamentos do período atual invoices.id,
db cards.id,
.select({ cards.name,
orcamentoId: budgets.id, cards.logo,
categoryId: budgets.categoryId, cards.dueDay,
budgetAmount: budgets.amount, invoices.period,
period: budgets.period, invoices.paymentStatus,
categoriaName: categories.name, );
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
}) // --- All 5 queries are independent — run in parallel ---
.from(budgets) const [
.innerJoin(categories, eq(budgets.categoryId, categories.id)) overdueInvoices,
.leftJoin(transactions, and(...budgetJoinConditions)) currentInvoices,
.where( nextPeriodInvoices,
and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)), boletosRows,
) budgetRows,
.groupBy(budgets.id, budgets.amount, categories.name), ] = await Promise.all([
]); // Faturas atrasadas (períodos anteriores)
db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
totalAmount: sql<
number | null
>`COALESCE(SUM(${transactions.amount}), 0)`,
})
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.leftJoin(
transactions,
and(
eq(transactions.cardId, invoices.cardId),
eq(transactions.period, invoices.period),
eq(transactions.userId, invoices.userId),
),
)
.where(
and(
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
)
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
),
// Faturas do período atual e próximo
buildPeriodInvoicesQuery(currentPeriod),
buildPeriodInvoicesQuery(nextPeriod),
// Boletos não pagos
db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
period: transactions.period,
})
.from(transactions)
.where(and(...boletosConditions)),
// Orçamentos do período atual
db
.select({
orcamentoId: budgets.id,
categoryId: budgets.categoryId,
budgetAmount: budgets.amount,
period: budgets.period,
categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(transactions, and(...budgetJoinConditions))
.where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
.groupBy(budgets.id, budgets.amount, categories.name),
]);
// ===================== // =====================
// Processar notificações // Processar notificações
@@ -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

@@ -24,24 +24,18 @@ const navLinks = [
interface MobileNavProps { interface MobileNavProps {
isPublicDomain: boolean; isPublicDomain: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
triggerClassName?: string;
} }
export function MobileNav({ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
isPublicDomain,
isLoggedIn,
triggerClassName,
}: MobileNavProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<div className="md:hidden"> <div className="md:hidden">
<Button <Button
variant="ghost" variant="navbar"
size="icon" size="icon-sm"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="Abrir menu" aria-label="Abrir menu"
className={triggerClassName}
> >
<RiMenuLine className="size-5" /> <RiMenuLine className="size-5" />
</Button> </Button>

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

@@ -35,9 +35,6 @@ export type FeatureItem = {
colorVar: string; colorVar: string;
}; };
export const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export const navLinks = [ export const navLinks = [
{ href: "#telas", label: "conheça as telas" }, { href: "#telas", label: "conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" }, { href: "#funcionalidades", label: "funcionalidades" },

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

@@ -41,7 +41,7 @@ const baseSchema = z.object({
.string() .string()
.trim() .trim()
.email("Informe um e-mail válido.") .email("Informe um e-mail válido.")
.optional() .nullish()
.transform((value) => normalizeOptionalString(value)), .transform((value) => normalizeOptionalString(value)),
status: statusEnum, status: statusEnum,
note: noteSchema, note: noteSchema,

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>
)} )}

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