37 Commits

Author SHA1 Message Date
Felipe Coutinho
10afef9fec fix(segurança): corrigir 10 vulnerabilidades do relatório de segurança
- tokens: remover aceite de expiresAt NULL e forçar TTL de 1 ano
- tokens: corrigir refresh que invalidava access token anterior
- xlsx: desabilitar parsing de fórmulas (CVE-2024-44294)
- csp: expandir Content-Security-Policy com origens explícitas
- headers: adicionar Referrer-Policy e X-Permitted-Cross-Domain-Policies
- api: retornar 401 JSON em vez de redirect 302 em rotas autenticadas
- health: remover version disclosure do /api/health
- robots.txt: simplificar para não expor rotas internas
- sitemap: corrigir URL com protocolo duplicado
- criar security.txt (RFC 9116)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:47:05 +00:00
Felipe Coutinho
fd4d90a53e ci: forçar Node.js 24 nas actions do workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:52:52 +00:00
Felipe Coutinho
a24406271c chore: corrigir formatacao do package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:51:14 +00:00
Felipe Coutinho
a09942e3d8 chore(release): preparar changelog da versão 2.3.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:46:47 +00:00
Felipe Coutinho
96febd5904 fix(docker): separar deps drizzle do node_modules standalone
O pnpm install no Stage 3 sobrescrevia o node_modules copiado do
.next/standalone, removendo o modulo next e quebrando o startup.

Agora as deps do drizzle-kit sao instaladas em /app/migrate/ antes
de copiar o standalone, mantendo os dois node_modules isolados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:45:48 +00:00
Felipe Coutinho
c3cfbc878c fix(tipografia): ajustar display da fonte america 2026-04-03 18:11:56 +00:00
Felipe Coutinho
55bbfabe9f chore(release): preparar changelog da versão 2.3.0 2026-04-03 18:11:34 +00:00
Felipe Coutinho
f5cdae4853 fix(ui): remover avisos visuais e destacar atualizações 2026-04-03 18:11:30 +00:00
Felipe Coutinho
5c4995961c refactor(lista): componentizar inbox e tabela de lançamentos 2026-04-03 18:10:58 +00:00
Felipe Coutinho
1b4dfaaba7 fix(lançamentos): reforçar validações e revisar formulário 2026-04-03 18:10:50 +00:00
Felipe Coutinho
549a5bdba1 fix(financeiro): alinhar saldo, métricas e relatórios 2026-04-03 18:10:43 +00:00
Felipe Coutinho
acaf9d5c27 feat(dados-client): adotar react query em leituras do app 2026-04-03 18:10:34 +00:00
Felipe Coutinho
e4c6a91350 fix(segurança): endurecer autenticação e rotas privadas 2026-04-03 18:10:23 +00:00
Felipe Coutinho
ba369e8a83 chore(infra): atualizar build, docker e tooling 2026-04-03 18:10:16 +00:00
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
246 changed files with 10600 additions and 3298 deletions

View File

@@ -1,58 +0,0 @@
# AI Coding Assistant Instructions for OpenMonetis
## Project Overview
OpenMonetis is a self-hosted personal finance management application built with Next.js 16, TypeScript, PostgreSQL, and Drizzle ORM. It provides manual transaction tracking, account management, budgeting, and financial insights with a Portuguese interface.
## Architecture
- **Frontend**: Next.js App Router with React 19, shadcn/ui components, Tailwind CSS
- **Backend**: Server actions in Next.js, API routes for auth/health
- **Database**: PostgreSQL with Drizzle ORM, schema in `db/schema.ts`
- **Auth**: Better Auth (OAuth + email magic links)
- **Deployment**: Docker multi-stage build, health checks
## Key Patterns
- **Server Actions**: Use `"use server"` for mutations, validate with Zod schemas, handle errors with `handleActionError`
- **Database Queries**: Use Drizzle's query API with relations, e.g., `db.query.lancamentos.findMany({ with: { categoria: true } })`
- **Authentication**: Import from `lib/auth/server`, redirect on failure
- **Revalidation**: Call `revalidateForEntity("lancamentos")` after mutations
- **Portuguese Naming**: DB fields like `nome`, `tipo_conta`, `pagador` (payer), `lancamento` (transaction)
- **Component Structure**: Feature-based folders in `components/`, shared UI in `components/ui/`
## Development Workflow
- **Start Dev**: `pnpm dev` (Turbopack), `docker compose up db -d` for DB
- **Database**: `pnpm db:push` to sync schema, `pnpm db:studio` for visual editor
- **Build**: `pnpm build`, `pnpm start` for production
- **Docker**: `pnpm docker:up` for full stack, `pnpm docker:logs` for monitoring
## Common Tasks
- **Add Transaction**: Create server action in `app/(dashboard)/lancamentos/actions.ts`, validate with Zod, insert via Drizzle
- **New Entity**: Add to `db/schema.ts`, define relations, create CRUD actions in `lib/[entity]/actions.ts`
- **UI Component**: Use shadcn/ui, place in `components/[feature]/`, export from `components/ui/`
- **API Route**: Add to `app/api/`, use `getUserSession()` for auth
## Conventions
- **Imports**: Absolute paths with `@/`, group by external/internal
- **Error Handling**: Return `{ success: false, error: string }` from actions
- **Currency**: Store as decimal strings (e.g., "123.45"), convert to cents for calculations
- **Periods**: Format as "YYYY-MM", use `parsePeriodParam()` for URL params
- **Notifications**: Send emails via `sendPagadorAutoEmails()` for payer updates
## External Integrations
- **Better Auth**: Config in `lib/auth/config.ts`, session handling
- **Drizzle**: Migrations in `drizzle/`, studio at `pnpm db:studio`
- **AI Features**: Use `@ai-sdk/*` for insights, configured in environment
- **Email**: Resend for notifications, configured via `RESEND_API_KEY`
## File Examples
- Schema: `db/schema.ts` (relations, indexes)
- Actions: `app/(dashboard)/lancamentos/actions.ts` (CRUD with validation)
- Components: `components/lancamentos/page/lancamentos-page.tsx` (client component)
- Utils: `lib/lancamentos/page-helpers.ts` (data transformation)

View File

@@ -13,10 +13,35 @@ on:
env:
DOCKER_IMAGE_NAME: openmonetis
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
build-and-push:
runs-on: ubuntu-latest
needs: quality
permissions:
contents: read
packages: write

View File

@@ -5,6 +5,9 @@ on:
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
release:
runs-on: ubuntu-latest

View File

@@ -29,4 +29,4 @@
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}
}

View File

@@ -7,6 +7,104 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased]
## [2.3.2] - 2026-04-04
### Segurança
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura
- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294
- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src`
- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies`
- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados
- Health: removido campo `version` da resposta do `/api/health`
- robots.txt: simplificado para não expor mapa de rotas internas
- Sitemap: corrigida URL com protocolo duplicado (`https://https://`)
- Criado `security.txt` (RFC 9116)
## [2.3.1] - 2026-04-03
### Corrigido
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
## [2.3.0] - 2026-04-03
### Adicionado
- Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side
- Dashboard: widget "Minhas Contas" ganha preferência persistida para mostrar ou ocultar contas marcadas como não consideradas no saldo total
- Dashboard: cards de métricas ganham botão de ajuda com explicação do cálculo exibido no app
- Versionamento: menu do usuário na navbar passa a avisar quando existe release mais recente publicada no GitHub
- Qualidade: adiciona `knip` ao projeto com o script `pnpm run lint:deadcode` para auditar arquivos, exports e tipos sem uso
- Infraestrutura: imagem Docker passa a rodar migrations automaticamente via `docker-entrypoint.sh` antes de iniciar a aplicação
### Alterado
- Anexos: listagem no modal de edição/detalhes, URLs temporárias da galeria e preview deixam de depender de `useEffect` para data fetching direto no componente e passam a usar React Query sobre rotas GET dedicadas
- Insights: carregamento de análises salvas passa a usar React Query com cache por período, mantendo estado draft local apenas para análises recém-geradas ou removidas
- Parcelamentos: histórico de antecipações no diálogo passa a usar React Query com invalidação automática após cancelamento
- Dashboard, insights e relatórios passam a excluir movimentações de contas marcadas como não consideradas no saldo total; balanço e previsto também passam a considerar ajustes de transferências entre contas consideradas e não consideradas
- UX: boletos e faturas passam a exibir labels relativas como "vence hoje", "vence amanhã" e "pago ontem", com tooltip para a data completa
- Lançamentos: diálogo foi reorganizado em blocos mais claros; a criação passa a aceitar múltiplos anexos e a edição em lote preserva `purchaseDate` e `period` ao propagar alterações por série
- Inbox e tabela de lançamentos foram componentizados em partes menores, mantendo paginação e ações em lote mais simples de evoluir
- Infraestrutura: workflow de publish ganha etapa obrigatória de qualidade; `docker-compose` passa a suportar perfil local ou banco remoto; build fixa `pnpm@10.33.0`; projeto atualizado para `Next.js 16.2.2`, `Biome 2.4.10` e dependências correlatas
- Qualidade: `knip` ganha configuração inicial para reduzir falsos positivos, ignorando `src/shared/components/ui/**`, o worker público de PDF, `setup.mjs` e o falso positivo de `postcss`
### Corrigido
- Segurança: criação de antecipações agora valida se `payerId` e `categoryId` informados pertencem ao usuário autenticado antes de persistir referências cruzadas
- Segurança: histórico de antecipações endurece os joins de `transactions`, `payers` e `categories` com filtro por `userId`, evitando exposição de nomes relacionados caso exista referência inconsistente no banco
- Segurança: domínio público deixa de responder rotas `/api/*`, e o Better Auth passa a aplicar rate limits explícitos para login e cadastro por e-mail
- APIs privadas: rotas de anexos, insights salvos, histórico de antecipações e presign de download passam a responder com `Cache-Control: private, no-store`; a rota de antecipações também deixa de devolver mensagens internas de erro ao cliente
- Build: rotas web de tokens do Companion passam a ser explicitamente dinâmicas, removendo o warning de prerender no `next build`
- Lançamentos: edição em série de compras parceladas volta a persistir `purchaseDate` e `period`, permitindo mover parcelas para a fatura ou competência correta conforme o escopo escolhido
- Lançamentos: edições que tentam mover compras de cartão para faturas já pagas agora são bloqueadas com mensagem clara também no fluxo de atualização e propagação em lote
- Imagens: logos institucionais, avatares padrão e componentes com `next/image` em modo `fill` passam a usar containers fixos com `sizes`, removendo avisos de proporção e performance
- Gráficos: `ChartContainer` passa a definir `initialDimension` no `ResponsiveContainer` do Recharts, evitando avisos `width(-1)` e `height(-1)` durante a medição inicial em widgets e relatórios
## [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

View File

@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
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.
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/`.
**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:
- auth: `src/shared/lib/auth/*`

View File

@@ -5,14 +5,16 @@
# ============================================
FROM node:22-alpine AS deps
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
# Copiar apenas arquivos de dependências para aproveitar cache
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)
RUN pnpm install --frozen-lockfile
@@ -21,8 +23,7 @@ RUN pnpm install --frozen-lockfile
# ============================================
FROM node:22-alpine AS builder
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
@@ -32,13 +33,14 @@ COPY --from=deps /app/node_modules ./node_modules
# Copiar todo o código fonte
COPY . .
# Garantir que o pdf.worker vem da versão instalada no stage 1, não do host
COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
# Variáveis de ambiente necessárias para o build
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
# Build da aplicação Next.js
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
RUN pnpm build
# ============================================
@@ -46,8 +48,7 @@ RUN pnpm build
# ============================================
FROM node:22-alpine AS runner
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
@@ -55,12 +56,27 @@ WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copiar apenas arquivos necessários para produção
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
COPY --from=builder /app/package.json /tmp/pkg.json
RUN mkdir -p /app/migrate && \
node -e "\
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
name:'openmonetis-migrate',version:p.version,\
dependencies:{\
'drizzle-orm':p.dependencies['drizzle-orm'],\
'pg':p.dependencies['pg']\
},\
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
}));" && \
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
chown -R nextjs:nodejs /app/migrate
# Copiar arquivos de build do Next.js
# Copiar apenas arquivos necessários para produção
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
@@ -69,8 +85,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
# Copiar node_modules para ter drizzle-kit disponível para migrations
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Copiar entrypoint de migrations
COPY docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
# Definir variáveis de ambiente de produção
ENV NODE_ENV=production \
@@ -81,16 +98,13 @@ ENV NODE_ENV=production \
# Expor porta
EXPOSE 3000
# Ajustar permissões para o usuário nextjs
RUN chown -R nextjs:nodejs /app
# Mudar para usuário não-root
USER nextjs
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
# Comando de inicialização
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
# Entrypoint: roda migrations e depois executa o CMD
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.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/)
[![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/)

View File

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

View File

@@ -4,23 +4,28 @@ name: openmonetis
# MODOS DE USO:
# 1. Banco LOCAL (PostgreSQL em container):
# - Configure DATABASE_URL com host "db" no .env
# - Execute: docker compose up
# - Execute: docker compose --profile local up
#
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
# - Configure DATABASE_URL com a URL do banco remoto no .env
# - Execute: docker compose up app (apenas o serviço app)
# - Execute: docker compose up
#
# 3. Para parar todos os serviços:
# 3. Build local (desenvolvimento):
# - Execute: docker compose --profile local up --build
#
# 4. Para parar todos os serviços:
# - Execute: docker compose down
#
# 4. Para remover volumes (CUIDADO: apaga dados do banco local):
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
# - Execute: docker compose down -v
services:
# ============================================
# Serviço: PostgreSQL (Banco de dados local)
# Ativado apenas com: --profile local
# ============================================
db:
profiles: ["local"]
image: postgres:18-alpine
container_name: openmonetis_postgres
restart: unless-stopped
@@ -63,6 +68,7 @@ services:
# Serviço: Aplicação Next.js
# ============================================
app:
build: .
image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app
@@ -80,6 +86,13 @@ services:
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
# S3 (opcional)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_BUCKET: ${S3_BUCKET:-}
# Email (opcional)
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
@@ -96,24 +109,11 @@ services:
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
# Só depende do 'db' se estiver usando banco local
# Para banco remoto, comente as linhas abaixo
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
depends_on:
db:
condition: service_healthy
# Script de inicialização: roda migrations antes de iniciar o app
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "Aguardando banco de dados..."
sleep 5
echo "Rodando migrations..."
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
echo "Iniciando aplicação Next.js..."
node server.js
required: false
healthcheck:
test:

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
echo "Rodando migrations..."
RETRIES=5
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
RETRIES=$((RETRIES - 1))
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
sleep 5
done
if [ "$RETRIES" -eq 0 ]; then
echo "Aviso: migrations nao foram aplicadas"
fi
exec "$@"

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,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
}
]
}

22
knip.jsonc Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
// Exclude shared UI primitives from dead code reporting while we focus the
// cleanup on feature and domain code first.
"ignore": [
"src/shared/components/ui/**"
],
// Runtime asset referenced by string in the PDF viewer.
"ignoreFiles": [
"public/pdf.worker.min.mjs",
"setup.mjs"
],
// PostCSS is inferred from the config file, but the project only depends on
// the Tailwind PostCSS plugin directly.
"ignoreDependencies": [
"postcss"
],
"next": true,
"postcss": true,
"biome": true,
"drizzle": true
}

View File

@@ -6,16 +6,20 @@ dotenv.config();
const nextConfig: NextConfig = {
output: "standalone",
experimental: {
turbopackFileSystemCacheForDev: true,
},
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
},
devIndicators: {
position: "bottom-right",
},
experimental: {
prefetchInlining: true,
turbopackFileSystemCacheForDev: true,
},
// Headers for Safari compatibility
async headers() {
return [
@@ -40,7 +44,23 @@ const nextConfig: NextConfig = {
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'none';",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://umami.felipecoutinho.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
"font-src 'self'",
"connect-src 'self' https://umami.felipecoutinho.com",
"frame-ancestors 'none'",
].join("; "),
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "X-Permitted-Cross-Domain-Policies",
value: "none",
},
{
key: "Permissions-Policy",

View File

@@ -1,13 +1,15 @@
{
"name": "openmonetis",
"version": "2.1.1",
"version": "2.3.2",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev --turbopack",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
"lint": "biome check .",
"lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh",
"db:generate": "drizzle-kit generate",
@@ -16,6 +18,7 @@
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up:db": "docker compose up -d db",
"docker:up:d": "docker compose up --build -d",
"docker:down": "docker compose down",
@@ -28,11 +31,11 @@
"backup": "bash scripts/backup.sh"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.64",
"@ai-sdk/google": "^3.0.53",
"@ai-sdk/openai": "^3.0.48",
"@aws-sdk/client-s3": "^3.1019.0",
"@aws-sdk/s3-request-presigner": "^3.1019.0",
"@ai-sdk/anthropic": "^3.0.65",
"@ai-sdk/google": "^3.0.55",
"@ai-sdk/openai": "^3.0.49",
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@better-auth/passkey": "^1.5.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -58,9 +61,10 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.141",
"ai": "^6.0.143",
"better-auth": "1.5.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
@@ -70,15 +74,16 @@
"drizzle-orm": "0.45.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "16.1.7",
"next": "16.2.2",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"recharts": "3.8.1",
"resend": "^6.9.4",
"resend": "^6.10.0",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"vaul": "1.1.2",
@@ -86,15 +91,16 @@
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.3.1",
"dotenv": "^17.4.0",
"drizzle-kit": "0.31.10",
"knip": "^6.3.0",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "6.0.2"

1483
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
Contact: https://github.com/felipegcoutinho/openmonetis/security/advisories
Expires: 2027-04-04T00:00:00.000Z
Preferred-Languages: pt-BR, en
Canonical: https://openmonetis.com/.well-known/security.txt

Binary file not shown.

View File

@@ -7,9 +7,12 @@ export const america = localFont({
weight: "400",
style: "normal",
},
{
path: "./america-medium.woff2",
weight: "500",
style: "normal",
},
],
display: "swap",
display: "fallback",
variable: "--font-america",
});
export const americaFontVariable = america.variable;

37
public/llms.txt Normal file
View File

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

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
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;
export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { accountId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -190,6 +192,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</section>
</main>

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { AccountsPage } from "@/features/accounts/components/accounts-page";
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
await connection();
const userId = await getUserId();
const { activeAccounts, archivedAccounts, logoOptions } =
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 { fetchBudgetsForUser } from "@/features/budgets/queries";
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);
export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
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 { fetchCalendarData } from "@/features/calendar/queries";
import {
@@ -16,6 +17,7 @@ type PageProps = {
};
export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined;

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import type { FinancialAccount } from "@/db/schema";
import { CardDialog } from "@/features/cards/components/card-dialog";
import type { Card } from "@/features/cards/components/types";
@@ -39,6 +40,7 @@ type PageProps = {
};
export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { cardId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -202,6 +204,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito"
lockCardSelection

View File

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

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
import { fetchUserPreferences } from "@/features/settings/queries";
@@ -32,6 +33,7 @@ const getSingleParam = (
};
export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { categoryId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -99,6 +101,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</main>
);

View File

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

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { CategoriesPage } from "@/features/categories/components/categories-page";
import { fetchCategoriesForUser } from "@/features/categories/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
await connection();
const userId = await getUserId();
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 { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
@@ -14,6 +15,7 @@ type PageProps = {
};
export default async function Page({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
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 {
type ResolvedInboxSearchParams,
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
};
export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams);

View File

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

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
@@ -9,6 +10,7 @@ export default async function DashboardLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
await connection();
const session = await getUserSession();
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 { fetchAllNotesForUser } from "@/features/notes/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
await connection();
const userId = await getUserId();
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);

View File

@@ -4,6 +4,7 @@ import {
RiWallet3Line,
} from "@remixicon/react";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
type OptionSet = ReturnType<typeof buildOptionSets>;
export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { payerId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -390,6 +392,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { connection } from "next/server";
import type { Category } from "@/db/schema";
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
@@ -29,6 +30,7 @@ const getSingleParam = (
};
export default async function Page({ searchParams }: PageProps) {
await connection();
// Get authenticated user
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 { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
export default async function TopEstabelecimentosPage({
searchParams,
}: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
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 { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { getUser } from "@/shared/lib/auth/server";
export default async function Page() {
await connection();
const user = await getUser();
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 { redirect } from "next/navigation";
import { connection } from "next/server";
import { CompanionTab } from "@/features/settings/components/companion-tab";
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 { fetchSettingsPageData } from "@/features/settings/queries";
import { Card } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import {
Tabs,
TabsContent,
@@ -20,6 +22,7 @@ import {
import { auth } from "@/shared/lib/auth/config";
export default async function Page() {
await connection();
const session = await auth.api.getSession({
headers: await headers(),
});
@@ -64,12 +67,13 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4">
<h2 className="text-xl font-medium mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades.
</p>
</div>
<Separator />
<PreferencesForm
statementNoteAsColumn={
userPreferences?.statementNoteAsColumn ?? false
@@ -77,25 +81,46 @@ export default async function Page() {
transactionsColumnOrder={
userPreferences?.transactionsColumnOrder ?? null
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</div>
</Card>
</TabsContent>
<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 value="nome" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4">
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
</p>
</div>
<Separator />
<UpdateNameForm currentName={userName} />
</div>
</Card>
@@ -105,12 +130,13 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4">
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
</p>
</div>
<Separator />
<UpdatePasswordForm authProvider={authProvider} />
</div>
</Card>
@@ -120,12 +146,13 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4">
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança.
</p>
</div>
<Separator />
<PasskeysForm />
</div>
</Card>
@@ -135,13 +162,14 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4">
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração.
</p>
</div>
<Separator />
<UpdateEmailForm
currentEmail={userEmail}
authProvider={authProvider}
@@ -154,14 +182,15 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1 text-destructive">
<h2 className="text-xl font-medium mb-1 text-destructive">
Ações perigosas
</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,
ou excluir sua conta inteira de forma irreversível.
</p>
</div>
<Separator />
<DeleteAccountForm />
</div>
</Card>

View File

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

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { fetchUserPreferences } from "@/features/settings/queries";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import {
@@ -27,6 +28,7 @@ type PageProps = {
};
export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -102,6 +104,7 @@ export default async function Page({ searchParams }: PageProps) {
}}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</main>
);

View File

@@ -126,7 +126,7 @@ export default async function Page() {
Projeto Open Source
</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,
<span className="text-primary"> do seu jeito</span>
</h1>
@@ -207,7 +207,7 @@ export default async function Page() {
className="flex flex-col items-center text-center gap-1.5"
>
<Icon className="size-5" style={{ color: colorVar }} />
<span className="text-2xl md:text-3xl font-bold">
<span className="text-2xl md:text-3xl font-medium">
{value}
</span>
<span className="text-xs md:text-sm text-muted-foreground">
@@ -229,7 +229,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Conheça as telas
</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
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -254,7 +254,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
O que tem aqui
</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
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -282,7 +282,7 @@ export default async function Page() {
/>
</div>
<div>
<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}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
@@ -346,7 +346,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" />
Mobile
</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
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -384,7 +384,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" />
PWA instalável
</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
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
@@ -430,7 +430,7 @@ export default async function Page() {
Companion Android
</Badge>
</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
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
@@ -441,7 +441,7 @@ export default async function Page() {
{companionSteps.map((step, index) => (
<li key={step.title} className="flex items-start gap-3">
<span
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
color: step.colorVar,
@@ -529,7 +529,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Stack técnica
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-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
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -556,7 +556,7 @@ export default async function Page() {
/>
</div>
<div>
<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}
</h3>
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
@@ -582,7 +582,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Como usar
</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
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -617,7 +617,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Para quem é?
</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
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -644,7 +644,7 @@ export default async function Page() {
/>
</div>
<div>
<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">
{item.description}
</p>
@@ -664,7 +664,7 @@ export default async function Page() {
<div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll>
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-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?
</h2>
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
@@ -715,7 +715,7 @@ export default async function Page() {
</div>
<div>
<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">
<li>
<Link
@@ -749,7 +749,7 @@ export default async function Page() {
</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">
<li>
<Link

View File

@@ -0,0 +1,54 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { attachments } from "@/db/schema";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ attachmentId: string }> },
) {
const [session, { attachmentId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
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,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const url = await createPresignedGetUrl(row.fileKey);
return NextResponse.json(
{ url },
{
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}

View File

@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
@@ -59,11 +58,11 @@ export async function POST(request: Request) {
);
}
// Atualizar hash do token e último uso
// Atualizar último uso e expiração (sem sobrescrever tokenHash,
// pois o JWT é auto-verificável por assinatura)
await db
.update(apiTokens)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||

View File

@@ -1,5 +1,5 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
@@ -16,14 +16,17 @@ const createTokenSchema = z.object({
});
export async function POST(request: Request) {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);

View File

@@ -1,6 +1,6 @@
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
@@ -10,16 +10,19 @@ interface RouteParams {
}
export async function DELETE(_request: Request, { params }: RouteParams) {
await connection();
const { tokenId } = await params;
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(

View File

@@ -1,19 +1,22 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
export async function GET() {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
const activeTokens = await db
.select({

View File

@@ -1,7 +1,7 @@
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
@@ -17,21 +17,21 @@ export async function POST(request: Request) {
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
// Verificar JWT (assinatura + expiração)
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ valid: false, error: "Token inválido ou expirado" },
{ status: 401 },
);
}
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco
// Buscar token no banco por tokenId para checar revogação
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ valid: false, error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { version as APP_VERSION } from "@/package.json";
import { db } from "@/shared/lib/db";
/**
@@ -20,7 +19,6 @@ export async function GET() {
{
status: "ok",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 },
@@ -33,7 +31,6 @@ export async function GET() {
{
status: "error",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: "Database connection failed",
},

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import {
fetchSavedInsights,
savedInsightsPeriodSchema,
} from "@/features/insights/queries";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(request: Request) {
const period = new URL(request.url).searchParams.get("period") ?? "";
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
if (!validatedPeriod.success) {
return NextResponse.json(
{
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const session = await getOptionalUserSession();
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const insights = await fetchSavedInsights(
session.user.id,
validatedPeriod.data,
);
return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ transactionId: string }> },
) {
const [session, { transactionId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ seriesId: string }> },
) {
try {
const [session, { seriesId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, {
headers: PRIVATE_RESPONSE_HEADERS,
});
} catch (error) {
console.error("Erro ao carregar histórico de antecipações:", error);
return NextResponse.json(
{
error: "Erro ao carregar histórico de antecipações.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
}

View File

@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { QueryProvider } from "@/shared/components/providers/query-provider";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css";
@@ -20,6 +22,7 @@ export default function RootLayout({
}>) {
return (
<html
data-scroll-behavior="smooth"
lang="pt-BR"
className={`${america.variable} ${america.className} `}
suppressHydrationWarning
@@ -33,10 +36,12 @@ export default function RootLayout({
data-domains="openmonetis.com"
/>
</head>
<body className="subpixel-antialiased" suppressHydrationWarning>
<body className="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
<QueryProvider>
<Suspense>{children}</Suspense>
<Toaster position="top-right" />
</QueryProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -1,35 +1,13 @@
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 {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard",
"/transactions",
"/accounts",
"/cards",
"/categories",
"/budgets",
"/payers",
"/notes",
"/insights",
"/calendar",
"/consultor",
"/settings",
"/reports",
"/inbox",
"/login",
"/api/",
],
disallow: "/api/",
},
],
sitemap: `${BASE_URL}/sitemap.xml`,
};
}

View File

@@ -1,7 +1,7 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
: "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap {

View File

@@ -135,9 +135,11 @@ export const userPreferences = pgTable("preferencias_usuario", {
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null
>(),
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[];
hidden: string[];
myAccountsShowExcluded?: boolean;
}>(),
createdAt: timestamp("created_at", {
mode: "date",

View File

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

View File

@@ -68,7 +68,7 @@ export function AccountStatementCard({
</div>
) : null}
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground">
<h2 className="truncate text-sm font-medium text-foreground">
{accountName}
</h2>
<p className="text-xs text-muted-foreground">
@@ -86,12 +86,12 @@ export function AccountStatementCard({
</p>
<MoneyValues
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">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-[11px]"
className="text-xs"
>
{status}
</Badge>
@@ -107,7 +107,7 @@ export function AccountStatementCard({
label="Saldo inicial"
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)}
</span>
</MetaItem>
@@ -116,7 +116,7 @@ export function AccountStatementCard({
label="Entradas"
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)}
</span>
</MetaItem>
@@ -125,7 +125,7 @@ export function AccountStatementCard({
label="Saídas"
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)}
</span>
</MetaItem>
@@ -136,7 +136,7 @@ export function AccountStatementCard({
>
<span
className={cn(
"text-sm font-semibold",
"text-sm font-medium",
resultado >= 0 ? "text-success" : "text-destructive",
)}
>

View File

@@ -0,0 +1,209 @@
"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
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
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,200 @@
"use client";
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiCloseLine,
RiDownloadLine,
RiExternalLinkLine,
} from "@remixicon/react";
import { useEffect, useState } from "react";
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
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 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;
const {
data: previewUrl,
isLoading: isPreviewLoading,
isError: isPreviewError,
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(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">
{isPreviewLoading && (
<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>
)}
{isPreviewError && (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
Não foi possível carregar a visualização deste anexo.
</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,54 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { fetchJson } from "@/shared/lib/fetch-json";
const ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;
export const attachmentUrlQueryKey = (attachmentId: string) =>
["attachments", "url", attachmentId] as const;
export function useAttachmentUrlQuery(attachmentId: string, enabled: boolean) {
return useQuery({
queryKey: attachmentUrlQueryKey(attachmentId),
queryFn: async () => {
const payload = await fetchJson<{ url: string }>(
`/api/attachments/${attachmentId}/presign`,
);
return payload.url;
},
enabled: enabled && Boolean(attachmentId),
staleTime: ATTACHMENT_URL_STALE_TIME,
gcTime: ATTACHMENT_URL_STALE_TIME * 2,
});
}
export function useAttachmentUrl(attachmentId: string) {
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
void attachmentId;
setIsVisible(false);
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
setIsVisible(true);
},
{ rootMargin: "150px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [attachmentId]);
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
return { url: url ?? null, 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) {
return (
<div className={cn("flex flex-col gap-2")}>
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
<div className={cn("flex flex-col gap-2.5")}>
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
{title}
</h1>
{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 hoje",
},
{
cardName: "Itaú",
logo: "itau.png",
amount: 1923,
dueLabel: "Vence amanhã",
},
];
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 { 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() {
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>
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
<Logo
variant="compact"
@@ -22,14 +46,25 @@ function AuthSidebar() {
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
/>
<div className="max-w-sm space-y-4.5">
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]">
Controle suas finanças com clareza e foco diário.
</h2>
<p className="max-w-2xs text-sm leading-6 text-black/68">
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia.
</p>
<div className="flex flex-1 items-center justify-center py-10">
<div className="w-full rotate-[1.5deg]">
<AuthSidebarInvoicesMock />
</div>
</div>
<div className="space-y-3">
<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>

View File

@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
});
if (passkeyError) {
setError((passkeyError.message as string) || "Erro ao entrar com passkey.");
setError(
(passkeyError.message as string) || "Erro ao entrar com passkey.",
);
setLoadingPasskey(false);
}
}
@@ -238,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
</a>
</FieldDescription>
<FieldDescription className="text-center text-[13px] text-muted-foreground">
<FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}>
Voltar para a página inicial
</a>

View File

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

View File

@@ -52,7 +52,7 @@ export function BudgetCard({
size="lg"
/>
<div className="space-y-1">
<h3 className="text-base font-semibold leading-tight">
<h3 className="text-base font-medium leading-tight">
{formatCategoryName(budget)}
</h3>
<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="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Limite atual</span>
<span className="font-semibold text-foreground">
<span className="font-medium text-foreground">
{formatCurrency(sliderValue)}
</span>
</div>

View File

@@ -19,7 +19,7 @@ export function CalendarGrid({
}: CalendarGridProps) {
return (
<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) => (
<span key={dayName} className="px-3 py-2 text-center text-primary">
{dayName}

View File

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

View File

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

View File

@@ -136,7 +136,7 @@ export function CardItem({
<div className="min-w-0">
<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}
</h3>
{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">
<span>
Fecha dia{" "}
<span className="font-semibold text-foreground">
<span className="font-medium text-foreground">
{formatDay(closingDay)}
</span>
</span>
<span>
Vence dia{" "}
<span className="font-semibold text-foreground">
<span className="font-medium text-foreground">
{formatDay(dueDay)}
</span>
</span>
@@ -206,7 +206,7 @@ export function CardItem({
<>
<div className="grid grid-cols-3 gap-4">
<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} />
</p>
<span className="text-xs font-medium text-muted-foreground">
@@ -215,7 +215,7 @@ export function CardItem({
</div>
<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" />
<MoneyValues amount={metrics[1].value} />
</p>
@@ -225,7 +225,7 @@ export function CardItem({
</div>
<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} />
</p>
<span className="text-xs font-medium text-muted-foreground">

View File

@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
size="lg"
/>
<div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight">
<h1 className="text-xl font-medium leading-tight">
{category.name}
</h1>
<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">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-semibold">
<p className="mt-1 text-2xl font-medium">
{currencyFormatter.format(currentTotal)}
</p>
</div>
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
</p>
<div
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,
)}
>

View File

@@ -3,6 +3,7 @@ import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
import {
buildFinancialStatusLabel,
buildRelativeFinancialStatusLabel,
formatFinancialDateLabel,
} from "@/shared/utils/financial-dates";
@@ -24,6 +25,14 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
});
};
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
return buildRelativeFinancialStatusLabel({
isSettled: bill.isSettled,
dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate,
});
};
export const getCurrentBillDateString = () => getBusinessDateString();
export const isBillOverdue = (bill: DashboardBill) => {

View File

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

View File

@@ -1,5 +1,10 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { budgets, categories, transactions } from "@/db/schema";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
@@ -8,6 +13,7 @@ import {
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(

View File

@@ -14,6 +14,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(

View File

@@ -20,6 +20,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
excludeTransactionsFromExcludedAccounts(),
or(
and(
eq(transactions.transactionType, "Despesa"),

View File

@@ -1,12 +1,18 @@
import { RiCheckboxCircleFill } from "@remixicon/react";
import {
buildBillStatusLabel,
buildBillWidgetStatusLabel,
isBillOverdue,
} from "@/features/dashboard/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
type BillListItemProps = {
@@ -15,8 +21,13 @@ type BillListItemProps = {
};
export function BillListItem({ bill, onPay }: BillListItemProps) {
const statusLabel = buildBillStatusLabel(bill);
const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill);
const statusTooltipLabel =
statusLabel && statusLabel !== absoluteStatusLabel
? absoluteStatusLabel
: null;
return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
@@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
statusTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"cursor-help rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{statusTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
)
) : null}
</div>
</div>

View File

@@ -56,7 +56,7 @@ export function BillPaymentDialog({
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
@@ -93,11 +93,11 @@ export function BillPaymentDialog({
{bill ? (
<div className="space-y-3">
{/* Card principal */}
<div className="rounded-xl border bg-muted/30 p-4">
<div className="rounded-xl border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto
</p>
<p className="text-base font-semibold text-foreground">
<p className="text-base font-medium text-foreground">
{bill.name}
</p>
</div>
@@ -107,24 +107,24 @@ export function BillPaymentDialog({
<div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide">
<span className="text-xs font-medium uppercase tracking-wide">
Valor
</span>
</div>
<MoneyValues
amount={bill.amount}
className="text-lg font-bold"
className="text-lg font-medium"
/>
</div>
<div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide">
<span className="text-xs font-medium uppercase tracking-wide">
Vencimento
</span>
</div>
<p className="text-sm font-semibold text-foreground">
<p className="text-sm font-medium text-foreground">
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
</p>
</div>

View File

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

View File

@@ -76,6 +76,9 @@ export function DashboardGridEditable({
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
);
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
initialPreferences?.myAccountsShowExcluded ?? true,
);
// Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
@@ -186,6 +189,7 @@ export function DashboardGridEditable({
if (result.success) {
setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]);
setMyAccountsShowExcluded(true);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
@@ -337,7 +341,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="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold">
<span className="text-xs font-medium">
Arraste para mover
</span>
<Button
@@ -361,7 +365,16 @@ export function DashboardGridEditable({
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
{widget.component({
data,
period,
widgetPreferences: {
order: widgetOrder,
hidden: hiddenWidgets,
myAccountsShowExcluded,
},
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})}
</ExpandableWidgetCard>
</div>
</SortableWidget>

View File

@@ -7,6 +7,7 @@ import {
RiScalesLine,
RiSubtractLine,
} from "@remixicon/react";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import MoneyValues from "@/shared/components/money-values";
import {
@@ -36,6 +37,14 @@ const CARDS = [
icon: RiArrowDownLine,
invertTrend: false,
iconClass: "text-success",
helpTitle: "Como calculamos receitas",
helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
],
},
{
label: "Despesas",
@@ -44,14 +53,29 @@ const CARDS = [
icon: RiArrowUpLine,
invertTrend: true,
iconClass: "text-destructive",
helpTitle: "Como calculamos despesas",
helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
],
},
{
label: "Balanço",
subtitle: "Receitas menos despesas",
subtitle: "Receitas, despesas e ajustes entre contas",
key: "balanco",
icon: RiScalesLine,
invertTrend: false,
iconClass: "text-warning",
helpTitle: "Como calculamos o balanço",
helpLines: [
"Partimos de receitas menos despesas do período.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.",
"Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.",
"Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",
],
},
{
label: "Previsto",
@@ -60,6 +84,13 @@ const CARDS = [
icon: RiCalendarCheckLine,
invertTrend: false,
iconClass: "text-cyan-600",
helpTitle: "Como calculamos o previsto",
helpLines: [
"Acumulamos o balanço mês a mês até o período atual.",
"Ele usa a mesma regra do card de balanço em cada mês do histórico.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora desse acumulado.",
"Por isso também reflete ajustes de transferências entre contas consideradas e não consideradas.",
],
},
] as const;
@@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map(
({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => {
({
label,
subtitle,
key,
icon: Icon,
invertTrend,
iconClass,
helpTitle,
helpLines,
}) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
@@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1 tracking-tight">
<CardTitle className="flex items-center gap-1.5 tracking-tight">
<Icon className={cn("size-4", iconClass)} aria-hidden />
{label}
<MetricsCardInfoButton
label={label}
helpTitle={helpTitle}
helpLines={helpLines}
/>
</CardTitle>
<CardDescription className="mt-1.5 tracking-tight">
{subtitle}
@@ -134,12 +179,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues
className="text-[1.55rem] leading-none font-medium"
className="text-2xl leading-none"
amount={metric.current}
/>
<div
className={cn(
"inline-flex items-center gap-1 text-xs font-medium",
"inline-flex items-center gap-1 text-xs ",
trendBadgeClass,
)}
>
@@ -150,7 +195,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<div className="text-xs text-muted-foreground">
<MoneyValues
className="inline text-xs font-medium text-muted-foreground"
className="inline text-xs text-muted-foreground"
amount={metric.previous}
/>
<span className="ml-1">no mês anterior</span>

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,10 @@ import {
buildInvoiceDetailsHref,
buildInvoiceInitials,
formatInvoicePaymentDate,
formatInvoiceWidgetPaymentDate,
getInvoiceShareLabel,
parseInvoiceDueDate,
parseInvoiceWidgetDueDate,
} from "@/features/dashboard/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
import MoneyValues from "@/shared/components/money-values";
@@ -20,6 +22,11 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/shared/components/ui/hover-card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { isDateOnlyPast } from "@/shared/utils/date";
@@ -31,14 +38,22 @@ type InvoiceListItemProps = {
};
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
const dueInfo = parseInvoiceWidgetDueDate(invoice.period, invoice.dueDay);
const absoluteDueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue =
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const paymentInfo = formatInvoiceWidgetPaymentDate(invoice.paidAt);
const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const dueTooltipLabel =
dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null;
const paymentTooltipLabel =
paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label
? absolutePaymentInfo?.label
: null;
const linkNode = (
<Link
@@ -100,7 +115,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
)}
</p>
</div>
<div className="text-sm font-semibold text-foreground">
<div className="text-sm font-medium text-foreground">
<MoneyValues amount={share.amount} />
</div>
</li>
@@ -113,9 +128,33 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{!isPaid ? (
dueTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help">{dueInfo.label}</span>
</TooltipTrigger>
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
</Tooltip>
) : (
<span>{dueInfo.label}</span>
)
) : null}
{isPaid && paymentInfo ? (
<span className="text-success">{paymentInfo.label}</span>
paymentTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help text-success">
{paymentInfo.label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{paymentTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span className="text-success">{paymentInfo.label}</span>
)
) : null}
</div>
</div>

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