32 Commits

Author SHA1 Message Date
Felipe Coutinho
c5df97f7aa chore(setup): reduzir logo e colocar nome ASCII lado a lado
Logo reduzido de 19 para 10 linhas (seleção dos frames-chave).
Nome do projeto em ASCII art posicionado ao lado direito do logo,
centralizado verticalmente. Tagline abaixo do bloco.

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:51:43 +00:00
Felipe Coutinho
fee2a2c9f5 fix(build): corrigir erros de tipo introduzidos pelo TypeScript 6.0
- Adiciona src/global.d.ts com declare module '*.css' para suportar
  side-effect imports de CSS com moduleResolution bundler
- Adiciona ignoreDeprecations "6.0" no tsconfig para silenciar aviso
  de depreciação do baseUrl (será removido no TS 7)
- Corrige cast de .message em better-auth 1.5.6, cujo tipo passou a
  ser string | RawError em chamadas de passkey

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:21:56 +00:00
Felipe Coutinho
839d7d0866 chore(release): publicar versão 2.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:47 +00:00
Felipe Coutinho
7cd7d95245 docs: atualizar README, .env.example e CLAUDE.md para a versão 2.1.0
Documenta variáveis S3 opcionais, instruções de self-hosting com anexos
e padrão de commit messages no guia do projeto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:44 +00:00
Felipe Coutinho
9bd762f7a3 chore(db): reorganizar migrations e adicionar tabelas de anexos
Consolida migrations anteriores e adiciona tabelas `anexos` e
`lancamento_anexos` com constraints de integridade referencial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:39 +00:00
Felipe Coutinho
9b76db4ce9 chore(deps): adicionar AWS SDK S3 e atualizar dependências
Adiciona @aws-sdk/client-s3 e @aws-sdk/s3-request-presigner para
suporte a anexos; atualiza ai-sdk, better-auth, drizzle-orm, recharts,
biome e typescript para versões mais recentes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:30 +00:00
Felipe Coutinho
91457b6490 chore(ci): adicionar workflow de release automático
Cria tag e GitHub Release a partir da versão do package.json e da
entrada correspondente no CHANGELOG.md ao fazer push na branch main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:26 +00:00
Felipe Coutinho
a0a71623d7 fix(ui): corrigir overflow do dialog e ícone de anexo nas categorias
Adiciona min-w-0 e overflow-x-hidden no DialogContent para evitar
expansão indevida; corrige referência do ícone RiAttachment2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:13 +00:00
Felipe Coutinho
00e624b8bc fix(lancamentos): bloquear criação em fatura já paga no cartão de crédito
Evita divergência no relatório de análise de parcelas ao impedir o
cadastro de lançamentos em períodos cujas faturas já foram quitadas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:10 +00:00
Felipe Coutinho
f82043127a feat(lancamentos): adicionar suporte a anexos com upload para storage S3
Permite vincular arquivos (PDF, imagens) a lançamentos via upload direto
para storage compatível com S3, usando token assinado por arquivo e
validação de propriedade na leitura e remoção.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:05 +00:00
Felipe Coutinho
32da4f906e fix(transactions): avoid crypto.randomUUID on initial load 2026-03-26 14:18:47 +00:00
Felipe Coutinho
0bd9d0ac47 chore(docker): simplify compose file for public self-hosting
Remove build step (use published image), strip verbose comments,
and inline pgcrypto init instead of external script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 19:16:56 +00:00
Felipe Coutinho
9f45fd1ecd chore(release): publish 2.0.2 2026-03-25 00:31:16 +00:00
Felipe Coutinho
f528e75ee1 style(ui): polish dashboard chrome and landing copy 2026-03-25 00:30:55 +00:00
Felipe Coutinho
da32b41bbc chore(tooling): add mockup helper and backup fixes 2026-03-25 00:30:46 +00:00
Felipe Coutinho
1e0c93fb6c fix(finance): preserve visibility and settlement updates 2026-03-25 00:29:36 +00:00
Felipe Coutinho
5f70421f5a feat(dashboard): persist notification center state 2026-03-25 00:29:24 +00:00
Felipe Coutinho
50477fb1be fix(inbox): corrigir agrupamento de data por fuso de Brasilia
O Companion envia hora local com 'Z' literal (nao converte para UTC),
entao o timestamp no DB ja carrega a data correta de Brasilia. Usava-se
+3h no frontend, que deslocava a virada de dia para as 21h locais e
fazia compras da tarde aparecerem como 'Ontem'.

- getItemDateKey: remove offset (data UTC ja e a data de Brasilia)
- getBrasiliaDateKey: usa UTC-3 apenas para calcular hoje/ontem
- Paraleliza insercoes no batch endpoint com Promise.allSettled
- Usa selectDistinct no fetchInboxSourceApps
- Envolve InboxCard em memo e callbacks em useCallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:55:46 +00:00
Felipe Coutinho
60a52b9873 fix(inbox): alinhar horario da tooltip do card 2026-03-21 19:42:55 +00:00
Felipe Coutinho
c9205f2be9 style(drizzle): normalizar snapshots gerados 2026-03-21 19:32:49 +00:00
Felipe Coutinho
1d36b12109 style: normalizar formatacao de importacao e suporte 2026-03-21 19:32:38 +00:00
Felipe Coutinho
19a1b1e943 chore(release): preparar versao 2.0.1 2026-03-21 19:31:53 +00:00
Felipe Coutinho
d3fc81db73 fix(inbox): melhorar filtros e identidade visual 2026-03-21 19:31:38 +00:00
Felipe Coutinho
80de9501f6 fix: move proxy.ts para src/ e atualiza dependências
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:52:20 +00:00
108 changed files with 13469 additions and 6652 deletions

View File

@@ -22,6 +22,13 @@ BETTER_AUTH_URL=http://localhost:3000
APP_PORT=3000
DB_PORT=5432
# === S3 Server (Opcional) ===
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
# === Email (Opcional) ===
# Provider: Resend (https://resend.com)
RESEND_API_KEY=

59
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read version from package.json
id: version
run: |
VERSION=$(jq -r '.version' package.json)
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Extract changelog for this version
if: steps.tag_check.outputs.exists == 'false'
id: changelog
run: |
VERSION="${{ steps.version.outputs.value }}"
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
# Remove linhas em branco do início e fim
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Create tag and GitHub Release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
body: ${{ steps.changelog.outputs.notes }}
draft: false
prerelease: false

View File

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

View File

@@ -5,6 +5,88 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [Unreleased]
## [2.1.1] - 2026-03-29
### Adicionado
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
- Analytics: integração com Umami self-hosted via script tag no layout raiz
### Alterado
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
### Removido
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
## [2.1.0] - 2026-03-28
### Adicionado
- Lançamentos: suporte a anexos em transações com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento
- Infraestrutura: novo workflow `.github/workflows/release.yml` para criar tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`
### Alterado
- Anexos: upload agora exige token assinado por arquivo, valida propriedade da transação também na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco
### Corrigido
- Lançamentos: criação de transações no cartão de crédito agora bloqueia períodos cujas faturas já estão pagas, evitando divergência no relatório de análise de parcelas
## [2.0.3] - 2026-03-26
### Corrigido
- Lançamentos: `/transactions` deixa de depender de `crypto.randomUUID()` no carregamento inicial, corrigindo a falha em ambientes self-hosted sem HTTPS ao abrir a página
## [2.0.2] - 2026-03-25
### Adicionado
- Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts`
- Navbar: novo estado persistido para notificações do sino, permitindo marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário
### Alterado
- Navbar: o snapshot global de notificações deixa de depender do `periodo` da URL atual e passa a usar o período corrente do negócio; itens lidos saem do badge e itens arquivados somem da lista padrão do sino
- Navbar: dropdown de notificações agora permite mostrar itens arquivados e reverter ações de leitura e arquivamento diretamente em cada item
- Navbar: filtro da lista de notificações no sino foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`, com destaque visual mais forte para a aba ativa
- Navbar: componente `notification-bell` foi desmembrado em hook e componentes locais menores, reduzindo acoplamento e facilitando manutenção
- Dashboard: detalhamento por categoria agora oculta categorias sem movimentação no período, reduzindo ruído visual no card
- UI: arte decorativa do topo da dashboard foi restrita à faixa do cabeçalho de boas-vindas, evitando que o `dot pattern` e o gradiente claro alterem a leitura visual do month picker
- Lançamentos em série: a edição em lote agora também permite propagar o status de pagamento (`isSettled`) para transações não feitas no cartão de crédito
- Seed de conta vazia: `scripts/mock-data.ts` agora processa `--help` antes de exigir `DATABASE_URL` e só cria categorias/pagador admin depois de validar que a conta está financeiramente vazia
### Corrigido
- Navbar: ao desarquivar a última notificação no modo de arquivadas, o dropdown volta automaticamente para a listagem padrão e o toggle deixa de ficar travado
- Filtros financeiros: transações de conta com observação nula, como compras parceladas no Pix, deixam de ser ocultadas indevidamente em `/transactions`, dashboard e relatórios quando a conta está configurada para desconsiderar o saldo inicial
- Backup: geração do arquivo `*.data.sql.gz` volta a usar a saída correta do `pg_restore`
### Removido
- DB: colunas `system_font` e `money_font` da tabela `preferencias_usuario`, que não são mais utilizadas no código
## [2.0.1] - 2026-03-21
### Corrigido
- Inbox: filtro por app em `/inbox` agora monta a lista completa de apps da aba a partir de todos os itens do status atual, sem depender apenas da página carregada, e o SSR deixa de quebrar quando `sourceApps` vier inconsistente
- Inbox: notificações de cartões/apps sem logo cadastrado agora exibem `default_icon.png` como fallback visual nos cards
- Inbox: select de apps em `/inbox` agora exibe os logos dos apps/cartões, com fallback para `default_icon.png` quando não houver logo mapeado
- Inbox: cabeçalhos de data entre grupos de cards agora exibem ícone e tipografia um pouco maior para melhorar a leitura
- Versionamento: `/api/health` passa a reportar a versão atual do `package.json`, evitando divergência entre healthcheck, UI e release publicada
## [2.0.0] - 2026-03-21
### Adicionado

View File

@@ -18,6 +18,7 @@
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.
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.
---

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.0.0-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.1.0-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/)
@@ -32,6 +32,7 @@
- [Início Rápido (manual)](#-início-rápido)
- [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker)
- [Storage S3 Compatível](#-storage-s3-compatível)
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
- [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo)
@@ -155,7 +156,7 @@ O script irá:
```bash
docker compose up db -d
pnpm db:enableExtensions
pnpm db:extensions
```
4. **Execute as migrations e inicie**
@@ -238,6 +239,30 @@ DB_PORT=5433 # Padrão: 5432
---
## ☁️ Storage S3 Compatível
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
### Variáveis
```env
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
```
### Compatibilidade
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
---
## 🔐 Variáveis de Ambiente
Copie `.env.example` para `.env` e configure:
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
POSTGRES_PASSWORD=openmonetis_dev_password
POSTGRES_DB=openmonetis_db
# S3 Server (opcional, necessario para anexos)
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
# Multi-domínio (landing-only no domínio público)
# PUBLIC_DOMAIN=openmonetis.com

View File

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

View File

@@ -4,11 +4,11 @@ name: openmonetis
# MODOS DE USO:
# 1. Banco LOCAL (PostgreSQL em container):
# - Configure DATABASE_URL com host "db" no .env
# - Execute: docker compose up --build
# - Execute: docker compose up
#
# 2. Banco REMOTO (ex: Supabase):
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
# - Configure DATABASE_URL com a URL do banco remoto no .env
# - Execute: docker compose up app --build (apenas o serviço app)
# - Execute: docker compose up app (apenas o serviço app)
#
# 3. Para parar todos os serviços:
# - Execute: docker compose down
@@ -29,22 +29,21 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
# Garante que os dados ficam no volume montado (evita perda após down/up)
PGDATA: /var/lib/postgresql/data
# Configurações de performance
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
ports:
# Mapeia porta 5432 do container para 5432 do host
# Útil para conectar com ferramentas externas (ex: DBeaver, pgAdmin)
- "${DB_PORT:-5432}:5432"
volumes:
# Volume nomeado para persistência de dados
# Os dados sobrevivem ao restart do container
- postgres_data:/var/lib/postgresql/data
# Script de inicialização (cria extensão pgcrypto automaticamente)
- ./scripts/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
exec docker-entrypoint.sh postgres
healthcheck:
test:
@@ -57,80 +56,65 @@ services:
retries: 5
start_period: 10s
networks:
- openmonetis_network
# Descomentar para ativar logs de queries (debug)
# command: ["postgres", "-c", "log_statement=all"]
# Para ativar logs de queries (debug), adicione ao command acima:
# exec docker-entrypoint.sh postgres -c log_statement=all
# ============================================
# Serviço: Aplicação Next.js
# ============================================
app:
build:
context: .
dockerfile: Dockerfile
image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app
restart: unless-stopped
ports:
# Mapeia porta 3000 do container para 3000 do host
- "${APP_PORT:-3000}:3000"
environment:
# Variáveis de ambiente da aplicação
NODE_ENV: production
# DATABASE_URL do .env
# Banco local: use host "db" (serviço Docker)
# Banco remoto: use a URL completa do provider
# Banco local: use host "db" | Banco remoto: URL completa do provider
DATABASE_URL: ${DATABASE_URL}
# Outras variáveis de ambiente necessárias
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
# Configurações de email (se usar)
# Email (opcional)
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
# Configurações de OAuth (se usar)
# OAuth (opcional)
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
# Configurações de AI providers (se usar)
# AI providers (opcional)
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
# Só depende do 'db' se estiver usando banco local
# Para banco remoto, comente a linha abaixo ou suba apenas: docker compose up app
# Para banco remoto, comente as linhas abaixo
depends_on:
db:
condition: service_healthy
networks:
- openmonetis_network
# Script de inicialização: roda migrations antes de iniciar o app
# ATENÇÃO: Em produção, considere rodar migrations separadamente por segurança
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "🚀 Aguardando banco de dados..."
echo "Aguardando banco de dados..."
sleep 5
echo "📦 Rodando migrations..."
pnpm db:push || echo "⚠️ Migrations falharam ou já estão atualizadas"
echo "Rodando migrations..."
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
echo "Iniciando aplicação Next.js..."
echo "Iniciando aplicação Next.js..."
node server.js
# Healthcheck da aplicação
healthcheck:
test:
[
@@ -151,13 +135,4 @@ services:
# ============================================
volumes:
postgres_data:
name: openmonetis_postgres_data
driver: local
# ============================================
# Networks
# ============================================
networks:
openmonetis_network:
name: openmonetis_network
driver: bridge

View File

@@ -0,0 +1 @@
-- placeholder: migration aplicada via db:push, arquivo original não preservado

View File

@@ -0,0 +1,37 @@
CREATE TABLE "anexos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"chave_arquivo" text NOT NULL,
"nome_arquivo" text NOT NULL,
"tamanho_bytes" integer NOT NULL,
"mime_type" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "anexos_chave_arquivo_unique" UNIQUE("chave_arquivo")
);
--> statement-breakpoint
CREATE TABLE "dashboard_notification_states" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"notification_key" text NOT NULL,
"fingerprint" text NOT NULL,
"read_at" timestamp with time zone,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lancamento_anexos" (
"lancamento_id" uuid NOT NULL,
"anexo_id" uuid NOT NULL,
CONSTRAINT "lancamento_anexos_lancamento_id_anexo_id_pk" PRIMARY KEY("lancamento_id","anexo_id")
);
--> statement-breakpoint
ALTER TABLE "anexos" ADD CONSTRAINT "anexos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_anexo_id_anexos_id_fk" FOREIGN KEY ("anexo_id") REFERENCES "public"."anexos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");--> statement-breakpoint
CREATE INDEX "lancamento_anexos_anexo_id_idx" ON "lancamento_anexos" USING btree ("anexo_id");--> statement-breakpoint
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";

View File

@@ -0,0 +1,14 @@
CREATE TABLE "dashboard_notification_states" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"notification_key" text NOT NULL,
"fingerprint" text NOT NULL,
"read_at" timestamp with time zone,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,167 +1,174 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
}
]
}

View File

@@ -1,10 +1,10 @@
{
"name": "openmonetis",
"version": "2.0.0",
"version": "2.1.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev-env": "tsx scripts/dev.ts",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
"lint": "biome check .",
@@ -13,7 +13,7 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:enableExtensions": "tsx scripts/postgres/enable-extensions.ts",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build",
"docker:up:db": "docker compose up -d db",
@@ -28,10 +28,12 @@
"backup": "bash scripts/backup.sh"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.62",
"@ai-sdk/google": "^3.0.51",
"@ai-sdk/openai": "^3.0.46",
"@better-auth/passkey": "^1.5.5",
"@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",
"@better-auth/passkey": "^1.5.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -58,16 +60,14 @@
"@remixicon/react": "4.9.0",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.127",
"better-auth": "1.5.5",
"ai": "^6.0.141",
"better-auth": "1.5.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "0.45.1",
"drizzle-orm": "0.45.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "16.1.7",
@@ -77,7 +77,7 @@
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"recharts": "3.8.0",
"recharts": "3.8.1",
"resend": "^6.9.4",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
@@ -86,17 +86,17 @@
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@biomejs/biome": "2.4.9",
"@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0",
"@types/pg": "^8.18.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.3.1",
"drizzle-kit": "0.31.10",
"tailwindcss": "4.2.1",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
}
}

1687
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -81,7 +81,7 @@ fi
# Extrai dados puros do dump custom (sem nova conexão ao banco)
pg_restore --data-only --schema=public --no-owner --no-privileges \
"$DUMP_FILE" | gzip > "$DATA_FILE"
-f - "$DUMP_FILE" | gzip > "$DATA_FILE"
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"

View File

@@ -135,11 +135,11 @@ type SeedSummary = {
function printUsage() {
console.log(`
Uso:
pnpm seed:empty-account -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
pnpm mockup -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
Exemplos:
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2026-01
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2025-10 --months=8
pnpm mockup -- --userId=user_123 --startPeriod=2026-01
pnpm mockup -- --userId=user_123 --startPeriod=2025-10 --months=8
`);
}
@@ -766,11 +766,12 @@ async function seedInvoicesForCards(params: {
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL não está configurada no ambiente.");
}
const options = parseArgs(process.argv.slice(2));
const logoOptions = await loadLogoOptions();
const avatarOptions = await loadAvatarOptions();
const businessToday = getBusinessTodayInfo();
@@ -794,9 +795,8 @@ async function main() {
throw new Error(`Usuário ${options.userId} não foi encontrado.`);
}
await ensureCategories(targetUser.id);
const adminPayer = await ensureAdminPayer(targetUser);
await assertFinancialSpaceIsEmpty(targetUser.id);
const adminPayer = await ensureAdminPayer(targetUser);
const categoriesByName = await ensureCategories(targetUser.id);

View File

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

View File

@@ -1,6 +1,7 @@
import { InboxPage } from "@/features/inbox/components/inbox-page";
import {
type ResolvedInboxSearchParams,
resolveInboxApp,
resolveInboxPagination,
resolveInboxStatus,
} from "@/features/inbox/page-helpers";
@@ -8,6 +9,7 @@ import {
fetchAppLogoMap,
fetchInboxDialogData,
fetchInboxItemsPage,
fetchInboxSourceApps,
fetchInboxStatusCounts,
} from "@/features/inbox/queries";
import { getUserId } from "@/shared/lib/auth/server";
@@ -32,21 +34,31 @@ export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams);
const activeApp = resolveInboxApp(resolvedSearchParams);
const paginationInput = resolveInboxPagination(resolvedSearchParams);
const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([
fetchInboxItemsPage(userId, activeStatus, paginationInput),
fetchInboxStatusCounts(userId),
activeStatus === "pending"
? fetchInboxDialogData(userId)
: Promise.resolve(EMPTY_DIALOG_DATA),
fetchAppLogoMap(userId),
]);
const [itemsPage, counts, sourceApps, dialogData, appLogoMap] =
await Promise.all([
fetchInboxItemsPage(userId, activeStatus, {
...paginationInput,
sourceApp: activeApp,
}),
fetchInboxStatusCounts(userId),
fetchInboxSourceApps(userId, activeStatus).catch(() => [] as string[]),
activeStatus === "pending"
? fetchInboxDialogData(userId)
: Promise.resolve(EMPTY_DIALOG_DATA),
fetchAppLogoMap(userId),
]);
const normalizedSourceApps = Array.isArray(sourceApps) ? sourceApps : [];
return (
<main className="flex flex-col items-start gap-6">
<InboxPage
activeStatus={activeStatus}
activeApp={activeApp}
sourceApps={normalizedSourceApps}
items={itemsPage.items}
counts={counts}
pagination={itemsPage.pagination}

View File

@@ -3,33 +3,14 @@ import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
export default async function DashboardLayout({
children,
searchParams,
}: Readonly<{
children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}>) {
const session = await getUserSession();
// Buscar notificações para o período atual
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = resolvedSearchParams?.periodo;
const singlePeriodoParam =
typeof periodoParam === "string"
? periodoParam
: Array.isArray(periodoParam)
? periodoParam[0]
: null;
const { period: currentPeriod } = parsePeriodParam(
singlePeriodoParam ?? null,
);
const navbarData = await fetchDashboardNavbarData(
session.user.id,
currentPeriod,
);
const navbarData = await fetchDashboardNavbarData(session.user.id);
return (
<PrivacyProvider>
@@ -40,7 +21,7 @@ export default async function DashboardLayout({
notificationsSnapshot={navbarData.notificationsSnapshot}
/>
<div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
<DotPattern
width={20}
height={20}

View File

@@ -1,14 +1,25 @@
import { ImportPage } from "@/features/transactions/components/import/import-page";
import {
buildOptionSets,
buildSluggedFilters,
} from "@/features/transactions/page-helpers";
import { fetchTransactionFilterSources } from "@/features/transactions/queries";
import { buildOptionSets, buildSluggedFilters } from "@/features/transactions/page-helpers";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const filterSources = await fetchTransactionFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const { payerOptions, accountOptions, cardOptions, categoryOptions, defaultPayerId } =
buildOptionSets({ ...sluggedFilters, payerRows: filterSources.payerRows });
const {
payerOptions,
accountOptions,
cardOptions,
categoryOptions,
defaultPayerId,
} = buildOptionSets({
...sluggedFilters,
payerRows: filterSources.payerRows,
});
return (
<main className="flex flex-col gap-6">

View File

@@ -17,7 +17,6 @@ import {
extraFeatures,
getMetricsItems,
mainFeatures,
navbarActionClassName,
navLinks,
pwaHighlights,
stackItems,
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
@@ -50,65 +50,60 @@ export default async function Page() {
return (
<div className="flex min-h-screen flex-col">
{/* Navigation */}
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary">
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between">
<Logo variant="compact" invertTextOnDark={false} />
<NavbarShell>
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
{navLinks.map(({ href, label }) => (
<a
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
>
{label}
</a>
))}
</nav>
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
{navLinks.map(({ href, label }) => (
<a
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
>
{label}
</a>
))}
</nav>
<nav className="flex items-center gap-2 md:gap-3">
<AnimatedThemeToggler className={navbarActionClassName} />
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<nav className="ml-auto flex items-center gap-2 md:gap-3">
<AnimatedThemeToggler variant="navbar" />
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="outline"
size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-2">
<Link href="/login">
<Button
variant="outline"
variant="ghost"
size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
>
Dashboard
Entrar
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-2">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
>
Entrar
</Button>
</Link>
<Link href="/signup">
<Button
size="sm"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
>
Começar
</Button>
</Link>
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
/>
</nav>
</div>
</header>
<Link href="/signup">
<Button
size="sm"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
>
Começar
</Button>
</Link>
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
/>
</nav>
</NavbarShell>
{/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">

View File

@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import { version as APP_VERSION } from "@/package.json";
import { db } from "@/shared/lib/db";
const APP_VERSION = "1.0.0";
/**
* Health check endpoint para Docker, monitoring e OpenMonetis Companion
* GET /api/health

View File

@@ -86,12 +86,10 @@ export async function POST(request: Request) {
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Processar cada item
const results: BatchResult[] = [];
for (const item of items) {
try {
const [inserted] = await db
// Processar todos os itens em paralelo
const settled = await Promise.allSettled(
items.map((item) =>
db
.insert(inboxItems)
.values({
userId: tokenRecord.userId,
@@ -104,22 +102,26 @@ export async function POST(request: Request) {
parsedAmount: item.parsedAmount?.toString(),
status: "pending",
})
.returning({ id: inboxItems.id });
.returning({ id: inboxItems.id }),
),
);
results.push({
clientId: item.clientId,
serverId: inserted.id,
const results: BatchResult[] = settled.map((result, i) => {
const item = items[i];
if (result.status === "fulfilled") {
return {
clientId: item?.clientId,
serverId: result.value[0]?.id,
success: true,
});
} catch (error) {
console.error("[API] Error processing batch item:", error);
results.push({
clientId: item.clientId,
success: false,
error: "Erro ao processar notificação",
});
};
}
}
console.error("[API] Error processing batch item:", result.reason);
return {
clientId: item?.clientId,
success: false,
error: "Erro ao processar notificação",
};
});
// Atualizar último uso do token
const clientIp =

View File

@@ -1,5 +1,3 @@
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
@@ -23,19 +21,23 @@ export default function RootLayout({
return (
<html
lang="pt-BR"
className={`${america.variable} ${america.className}`}
className={`${america.variable} ${america.className} `}
suppressHydrationWarning
>
<head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
<script
defer
src="https://umami.felipecoutinho.com/script.js"
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
data-domains="openmonetis.com"
/>
</head>
<body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
</html>
);

View File

@@ -132,8 +132,6 @@ export const userPreferences = pgTable("preferencias_usuario", {
statementNoteAsColumn: boolean("extrato_note_as_column")
.notNull()
.default(false),
systemFont: text("system_font").notNull().default("ai-sans"),
moneyFont: text("money_font").notNull().default("ai-sans"),
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null
>(),
@@ -527,6 +525,40 @@ export const inboxItems = pgTable(
}),
);
export const dashboardNotificationStates = pgTable(
"dashboard_notification_states",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
notificationKey: text("notification_key").notNull(),
fingerprint: text("fingerprint").notNull(),
readAt: timestamp("read_at", {
mode: "date",
withTimezone: true,
}),
archivedAt: timestamp("archived_at", {
mode: "date",
withTimezone: true,
}),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
userIdNotificationKeyUnique: uniqueIndex(
"dashboard_notification_states_user_id_key_unique",
).on(table.userId, table.notificationKey),
userIdArchivedAtIdx: index(
"dashboard_notification_states_user_id_archived_idx",
).on(table.userId, table.archivedAt),
}),
);
export const installmentAnticipations = pgTable(
"antecipacoes_parcelas",
{
@@ -815,32 +847,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
}),
}));
export const transactionsRelations = relations(transactions, ({ one }) => ({
user: one(user, {
fields: [transactions.userId],
references: [user.id],
export const transactionsRelations = relations(
transactions,
({ one, many }) => ({
user: one(user, {
fields: [transactions.userId],
references: [user.id],
}),
card: one(cards, {
fields: [transactions.cardId],
references: [cards.id],
}),
financialAccount: one(financialAccounts, {
fields: [transactions.accountId],
references: [financialAccounts.id],
}),
category: one(categories, {
fields: [transactions.categoryId],
references: [categories.id],
}),
payer: one(payers, {
fields: [transactions.payerId],
references: [payers.id],
}),
anticipation: one(installmentAnticipations, {
fields: [transactions.anticipationId],
references: [installmentAnticipations.id],
}),
transactionAttachments: many(transactionAttachments),
}),
card: one(cards, {
fields: [transactions.cardId],
references: [cards.id],
}),
financialAccount: one(financialAccounts, {
fields: [transactions.accountId],
references: [financialAccounts.id],
}),
category: one(categories, {
fields: [transactions.categoryId],
references: [categories.id],
}),
payer: one(payers, {
fields: [transactions.payerId],
references: [payers.id],
}),
anticipation: one(installmentAnticipations, {
fields: [transactions.anticipationId],
references: [installmentAnticipations.id],
}),
}));
);
export const installmentAnticipationsRelations = relations(
installmentAnticipations,
@@ -864,6 +900,40 @@ export const installmentAnticipationsRelations = relations(
}),
);
// ===================== ATTACHMENTS =====================
export const attachments = pgTable("anexos", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileKey: text("chave_arquivo").notNull().unique(),
fileName: text("nome_arquivo").notNull(),
fileSize: integer("tamanho_bytes").notNull(),
mimeType: text("mime_type").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
});
export const transactionAttachments = pgTable(
"lancamento_anexos",
{
transactionId: uuid("lancamento_id")
.notNull()
.references(() => transactions.id, { onDelete: "cascade" }),
attachmentId: uuid("anexo_id")
.notNull()
.references(() => attachments.id, { onDelete: "cascade" }),
},
(table) => ({
pk: primaryKey({ columns: [table.transactionId, table.attachmentId] }),
attachmentIdIdx: index("lancamento_anexos_anexo_id_idx").on(
table.attachmentId,
),
}),
);
export const importCategoryMappings = pgTable(
"import_category_mappings",
{
@@ -907,3 +977,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
export type InboxItem = typeof inboxItems.$inferSelect;
export type NewInboxItem = typeof inboxItems.$inferInsert;
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
user: one(user, {
fields: [attachments.userId],
references: [user.id],
}),
transactionAttachments: many(transactionAttachments),
}));
export const transactionAttachmentsRelations = relations(
transactionAttachments,
({ one }) => ({
transaction: one(transactions, {
fields: [transactionAttachments.transactionId],
references: [transactions.id],
}),
attachment: one(attachments, {
fields: [transactionAttachments.attachmentId],
references: [attachments.id],
}),
}),
);
export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;

View File

@@ -48,21 +48,20 @@ const accountBaseSchema = z.object({
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
initialBalance: z
.union([
z.number(),
z
.string()
.trim()
.transform((value) =>
value.length === 0 ? "0" : value.replace(",", "."),
)
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido.",
)
.transform((value) => Number.parseFloat(value)),
]),
initialBalance: z.union([
z.number(),
z
.string()
.trim()
.transform((value) =>
value.length === 0 ? "0" : value.replace(",", "."),
)
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido.",
)
.transform((value) => Number.parseFloat(value)),
]),
excludeFromBalance: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),

View File

@@ -125,7 +125,7 @@ export function LoginForm({ className, ...props }: DivProps) {
});
if (passkeyError) {
setError(passkeyError.message || "Erro ao entrar com passkey.");
setError((passkeyError.message as string) || "Erro ao entrar com passkey.");
setLoadingPasskey(false);
}
}

View File

@@ -111,10 +111,11 @@ export function buildCategoryBreakdownData({
});
}
categories.sort((a, b) => b.currentAmount - a.currentAmount);
const filtered = categories.filter((c) => c.currentAmount > 0);
filtered.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
categories: filtered,
currentTotal,
previousTotal,
};

View File

@@ -111,6 +111,7 @@ export async function fetchCategoryDetails(
sanitizedNote,
eq(transactions.period, previousPeriod),
or(
isNull(transactions.note),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),

View File

@@ -4,6 +4,7 @@ import { payers } from "@/db/schema";
import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getBusinessDateString } from "@/shared/utils/date";
import {
type DashboardNotificationsSnapshot,
fetchDashboardNotifications,
@@ -36,8 +37,8 @@ async function fetchAdminPayerAvatarUrl(
async function fetchDashboardNavbarDataInternal(
userId: string,
currentPeriod: string,
): Promise<DashboardNavbarData> {
const currentPeriod = getBusinessDateString().slice(0, 7);
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
await Promise.all([
fetchAdminPayerAvatarUrl(userId),
@@ -52,12 +53,11 @@ async function fetchDashboardNavbarDataInternal(
};
}
export function fetchDashboardNavbarData(
userId: string,
currentPeriod: string,
) {
export function fetchDashboardNavbarData(userId: string) {
const currentPeriod = getBusinessDateString().slice(0, 7);
return unstable_cache(
() => fetchDashboardNavbarDataInternal(userId, currentPeriod),
() => fetchDashboardNavbarDataInternal(userId),
[`dashboard-navbar-${userId}-${currentPeriod}`],
{
tags: [`dashboard-${userId}`],

View File

@@ -0,0 +1,252 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { dashboardNotificationStates } from "@/db/schema";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
import type { ActionResult } from "@/shared/lib/types/actions";
const notificationStateSchema = z.object({
notificationKey: z
.string({ message: "Chave da notificação inválida." })
.trim()
.min(1, "Chave da notificação inválida."),
fingerprint: z
.string({ message: "Fingerprint da notificação inválido." })
.trim()
.min(1, "Fingerprint da notificação inválido."),
});
type DashboardNotificationStateInput = z.infer<typeof notificationStateSchema>;
function revalidateNotifications(userId: string) {
revalidateForEntity("notifications", userId);
}
async function getExistingNotificationState(
userId: string,
notificationKey: string,
) {
const [existing] = await db
.select({
id: dashboardNotificationStates.id,
archivedAt: dashboardNotificationStates.archivedAt,
})
.from(dashboardNotificationStates)
.where(
and(
eq(dashboardNotificationStates.userId, userId),
eq(dashboardNotificationStates.notificationKey, notificationKey),
),
)
.limit(1);
return existing ?? null;
}
export async function markDashboardNotificationAsReadAction(
input: DashboardNotificationStateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = notificationStateSchema.parse(input);
const now = new Date();
const existing = await getExistingNotificationState(
user.id,
data.notificationKey,
);
if (existing) {
await db
.update(dashboardNotificationStates)
.set({
fingerprint: data.fingerprint,
readAt: now,
archivedAt: existing.archivedAt,
updatedAt: now,
})
.where(
and(
eq(dashboardNotificationStates.userId, user.id),
eq(
dashboardNotificationStates.notificationKey,
data.notificationKey,
),
),
);
} else {
await db.insert(dashboardNotificationStates).values({
userId: user.id,
notificationKey: data.notificationKey,
fingerprint: data.fingerprint,
readAt: now,
archivedAt: null,
updatedAt: now,
});
}
revalidateNotifications(user.id);
return { success: true, message: "Notificação marcada como lida." };
} catch (error) {
if (isNotificationStatesTableMissing(error)) {
return {
success: false,
error:
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
};
}
return handleActionError(error);
}
}
export async function markDashboardNotificationAsUnreadAction(
input: DashboardNotificationStateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = notificationStateSchema.parse(input);
const now = new Date();
const existing = await getExistingNotificationState(
user.id,
data.notificationKey,
);
if (!existing) {
return { success: true, message: "Notificação marcada como não lida." };
}
await db
.update(dashboardNotificationStates)
.set({
fingerprint: data.fingerprint,
readAt: null,
archivedAt: existing.archivedAt,
updatedAt: now,
})
.where(
and(
eq(dashboardNotificationStates.userId, user.id),
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
),
);
revalidateNotifications(user.id);
return { success: true, message: "Notificação marcada como não lida." };
} catch (error) {
if (isNotificationStatesTableMissing(error)) {
return {
success: false,
error:
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
};
}
return handleActionError(error);
}
}
export async function archiveDashboardNotificationAction(
input: DashboardNotificationStateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = notificationStateSchema.parse(input);
const now = new Date();
await db
.insert(dashboardNotificationStates)
.values({
userId: user.id,
notificationKey: data.notificationKey,
fingerprint: data.fingerprint,
readAt: now,
archivedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
dashboardNotificationStates.userId,
dashboardNotificationStates.notificationKey,
],
set: {
fingerprint: data.fingerprint,
readAt: now,
archivedAt: now,
updatedAt: now,
},
});
revalidateNotifications(user.id);
return { success: true, message: "Notificação arquivada." };
} catch (error) {
if (isNotificationStatesTableMissing(error)) {
return {
success: false,
error:
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
};
}
return handleActionError(error);
}
}
export async function unarchiveDashboardNotificationAction(
input: DashboardNotificationStateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = notificationStateSchema.parse(input);
const now = new Date();
const existing = await getExistingNotificationState(
user.id,
data.notificationKey,
);
if (!existing) {
return {
success: false,
error: "Notificação não encontrada para restaurar.",
};
}
await db
.update(dashboardNotificationStates)
.set({
fingerprint: data.fingerprint,
archivedAt: null,
readAt: now,
updatedAt: now,
})
.where(
and(
eq(dashboardNotificationStates.userId, user.id),
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
),
);
revalidateNotifications(user.id);
return { success: true, message: "Notificação restaurada." };
} catch (error) {
if (isNotificationStatesTableMissing(error)) {
return {
success: false,
error:
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
};
}
return handleActionError(error);
}
}

View File

@@ -1,16 +1,24 @@
"use server";
import { and, eq, lt, ne, sql } from "drizzle-orm";
import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
import {
budgets,
cards,
categories,
dashboardNotificationStates,
invoices,
transactions,
} from "@/db/schema";
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import type {
BudgetNotification,
DashboardNotification,
DashboardNotificationsSnapshot,
} from "@/shared/lib/types/notifications";
import {
buildDateOnlyStringFromPeriodDay,
getBusinessDateString,
@@ -19,41 +27,65 @@ import {
toDateOnlyString,
} from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { formatPeriodForUrl } from "@/shared/utils/period";
export type NotificationType = "overdue" | "due_soon";
export type DashboardNotification = {
id: string;
type: "invoice" | "boleto";
name: string;
dueDate: string;
status: NotificationType;
amount: number;
period?: string;
showAmount: boolean;
cardLogo?: string | null;
};
export type BudgetStatus = "exceeded" | "critical";
export type BudgetNotification = {
id: string;
categoryName: string;
budgetAmount: number;
spentAmount: number;
usedPercentage: number;
status: BudgetStatus;
};
export type DashboardNotificationsSnapshot = {
notifications: DashboardNotification[];
totalCount: number;
budgetNotifications: BudgetNotification[];
};
export type {
BudgetNotification,
BudgetStatus,
DashboardNotification,
DashboardNotificationsSnapshot,
NotificationType,
} from "@/shared/lib/types/notifications";
const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80;
type PersistedNotificationState = {
notificationKey: string;
fingerprint: string;
readAt: Date | null;
archivedAt: Date | null;
};
const buildInvoiceNotificationKey = (cardId: string, period: string) =>
`invoice-${cardId}-${period}`;
const buildBoletoNotificationKey = (transactionId: string) =>
`boleto-${transactionId}`;
const buildBudgetNotificationKey = (
categoryId: string | null,
budgetId: string,
period: string,
) => (categoryId ? `budget-${categoryId}-${period}` : `budget-${budgetId}`);
function mergeNotificationState<
T extends {
notificationKey: string;
fingerprint: string;
isRead: boolean;
isArchived: boolean;
readAt: Date | null;
archivedAt: Date | null;
},
>(items: T[], stateByKey: Map<string, PersistedNotificationState>): T[] {
return items.map((item) => {
const persisted = stateByKey.get(item.notificationKey);
if (!persisted || persisted.fingerprint !== item.fingerprint) {
return item;
}
return {
...item,
isRead: persisted.readAt !== null,
isArchived: persisted.archivedAt !== null,
readAt: persisted.readAt,
archivedAt: persisted.archivedAt,
};
});
}
/**
* Busca todas as notificações do dashboard:
* - Faturas de cartão atrasadas ou com vencimento próximo
@@ -188,7 +220,9 @@ export async function fetchDashboardNotifications(
db
.select({
orcamentoId: budgets.id,
categoryId: budgets.categoryId,
budgetAmount: budgets.amount,
period: budgets.period,
categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
@@ -216,12 +250,12 @@ export async function fetchDashboardNotifications(
);
if (!dueDate) continue;
const amount = toNumber(invoice.totalAmount);
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
const notificationKey = buildInvoiceNotificationKey(
invoice.cardId,
invoice.period,
);
notifications.push({
id: notificationId,
type: "invoice",
name: invoice.cardName,
dueDate,
@@ -230,6 +264,13 @@ export async function fetchDashboardNotifications(
period: invoice.period,
showAmount: true,
cardLogo: invoice.cardLogo,
notificationKey,
fingerprint: "overdue",
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
}
@@ -261,20 +302,28 @@ export async function fetchDashboardNotifications(
);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon";
const notificationKey = buildInvoiceNotificationKey(
invoice.cardId,
invoice.period,
);
notifications.push({
id: notificationId,
type: "invoice",
name: invoice.cardName,
dueDate,
status: invoiceIsOverdue ? "overdue" : "due_soon",
status: notificationStatus,
amount: Math.abs(amount),
period: invoice.period,
showAmount: invoiceIsOverdue,
cardLogo: invoice.cardLogo,
notificationKey,
fingerprint: notificationStatus,
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
}
@@ -292,10 +341,11 @@ export async function fetchDashboardNotifications(
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
const amount = toNumber(boleto.amount);
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
const notificationKey = buildBoletoNotificationKey(boleto.id);
if (isOldPeriod) {
notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto",
name: boleto.name,
dueDate,
@@ -303,17 +353,32 @@ export async function fetchDashboardNotifications(
amount: Math.abs(amount),
period: boleto.period,
showAmount: true,
notificationKey,
fingerprint: "overdue",
href,
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon";
notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto",
name: boleto.name,
dueDate,
status: boletoIsOverdue ? "overdue" : "due_soon",
status: notificationStatus,
amount: Math.abs(amount),
period: boleto.period,
showAmount: boletoIsOverdue,
notificationKey,
fingerprint: notificationStatus,
href,
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
}
}
@@ -335,14 +400,26 @@ export async function fetchDashboardNotifications(
const usedPercentage = (spentAmount / budgetAmount) * 100;
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical";
const notificationKey = buildBudgetNotificationKey(
row.categoryId,
row.orcamentoId,
row.period,
);
budgetNotifications.push({
id: `budget-${row.orcamentoId}`,
categoryName: row.categoriaName,
budgetAmount,
spentAmount,
usedPercentage,
status: usedPercentage >= 100 ? "exceeded" : "critical",
status: notificationStatus,
notificationKey,
fingerprint: notificationStatus,
href: `/budgets?periodo=${formatPeriodForUrl(row.period)}`,
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
}
@@ -353,9 +430,68 @@ export async function fetchDashboardNotifications(
return b.usedPercentage - a.usedPercentage;
});
return {
notifications,
totalCount: notifications.length,
const notificationKeys = [
...notifications.map((notification) => notification.notificationKey),
...budgetNotifications.map((notification) => notification.notificationKey),
];
let persistedStates: PersistedNotificationState[] = [];
if (notificationKeys.length > 0) {
try {
persistedStates = await db
.select({
notificationKey: dashboardNotificationStates.notificationKey,
fingerprint: dashboardNotificationStates.fingerprint,
readAt: dashboardNotificationStates.readAt,
archivedAt: dashboardNotificationStates.archivedAt,
})
.from(dashboardNotificationStates)
.where(
and(
eq(dashboardNotificationStates.userId, userId),
inArray(
dashboardNotificationStates.notificationKey,
notificationKeys,
),
),
);
} catch (error) {
if (isNotificationStatesTableMissing(error)) {
console.warn(
"[DashboardNotifications] Tabela dashboard_notification_states ainda não existe. Voltando ao modo sem persistência.",
);
} else {
throw error;
}
}
}
const stateByKey = new Map(
persistedStates.map((state) => [state.notificationKey, state]),
);
const mergedNotifications = mergeNotificationState(notifications, stateByKey);
const mergedBudgetNotifications = mergeNotificationState(
budgetNotifications,
stateByKey,
);
const visibleNotifications = mergedNotifications.filter(
(notification) => !notification.isArchived,
);
const visibleBudgetNotifications = mergedBudgetNotifications.filter(
(notification) => !notification.isArchived,
);
const unreadCount = [
...visibleNotifications,
...visibleBudgetNotifications,
].filter((notification) => !notification.isRead).length;
return {
notifications: mergedNotifications,
budgetNotifications: mergedBudgetNotifications,
unreadCount,
visibleCount:
visibleNotifications.length + visibleBudgetNotifications.length,
};
}

View File

@@ -50,6 +50,7 @@ export const excludeAutoGeneratedEntryNotes = () =>
export const excludeInitialBalanceWhenConfigured = () =>
or(
isNull(transactions.note),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),

View File

@@ -9,6 +9,7 @@ import {
import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { memo } from "react";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
@@ -20,16 +21,15 @@ import {
CardTitle,
} from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo";
import type { InboxItem } from "./types";
// O timestamp vem do app Android em horário local mas salvo como UTC.
// Adicionamos o offset de Brasília para corrigir o cálculo de "há X tempo".
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
function adjustToBrasilia(date: Date): Date {
return new Date(date.getTime() + BRASILIA_OFFSET_MS);
}
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function findMatchingLogo(
sourceAppName: string | null,
@@ -63,7 +63,7 @@ interface InboxCardProps {
onSelectToggle?: (id: string) => void;
}
export function InboxCard({
export const InboxCard = memo(function InboxCard({
item,
readonly,
appLogoMap,
@@ -78,17 +78,19 @@ export function InboxCard({
const matchedLogo = appLogoMap
? findMatchingLogo(item.sourceAppName, appLogoMap)
: null;
const displayLogo = matchedLogo ?? DEFAULT_INBOX_APP_LOGO;
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
const rawDate = new Date(item.notificationTimestamp);
const notificationDate = adjustToBrasilia(rawDate);
const createdAtDate = new Date(item.createdAt);
const timeAgo = formatDistanceToNow(notificationDate, {
const timeAgo = formatDistanceToNow(createdAtDate, {
addSuffix: true,
locale: ptBR,
});
const fullDate = format(createdAtDate, "PPpp", { locale: ptBR });
const statusDate =
item.status === "processed"
? item.processedAt
@@ -107,21 +109,32 @@ export function InboxCard({
<CardHeader className="pt-4">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
{matchedLogo && (
<Image
src={matchedLogo}
alt=""
width={24}
height={24}
className="shrink-0 rounded-full"
{onSelectToggle && (
<Checkbox
checked={!!selected}
onCheckedChange={() => onSelectToggle(item.id)}
aria-label="Selecionar item"
className="shrink-0"
/>
)}
<Image
src={displayLogo}
alt=""
width={32}
height={32}
className="shrink-0 rounded-full"
/>
<span className="truncate">
{item.sourceAppName || item.sourceApp}
</span>
<span className="shrink-0 text-xs font-normal text-muted-foreground">
{timeAgo}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 cursor-default text-xs font-normal text-muted-foreground underline decoration-dotted underline-offset-2">
{timeAgo}
</span>
</TooltipTrigger>
<TooltipContent>{fullDate}</TooltipContent>
</Tooltip>
</CardTitle>
{amount !== null && (
<MoneyValues amount={amount} className="shrink-0 text-sm" />
@@ -174,13 +187,6 @@ export function InboxCard({
<RiDeleteBinLine className="size-4" />
</Button>
)}
{onSelectToggle && (
<Checkbox
checked={!!selected}
onCheckedChange={() => onSelectToggle(item.id)}
aria-label="Selecionar item"
/>
)}
</div>
</CardFooter>
) : (
@@ -213,15 +219,8 @@ export function InboxCard({
>
<RiDeleteBinLine className="size-4" />
</Button>
{onSelectToggle && (
<Checkbox
checked={!!selected}
onCheckedChange={() => onSelectToggle(item.id)}
aria-label="Selecionar item"
/>
)}
</CardFooter>
)}
</Card>
);
}
});

View File

@@ -52,7 +52,14 @@ export function InboxDetailsDialog({
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">App</span>
<span>{item.sourceAppName || item.sourceApp}</span>
<div className="flex flex-col items-end gap-0.5">
<span>{item.sourceAppName || item.sourceApp}</span>
{item.sourceAppName && (
<span className="font-mono text-xs text-muted-foreground">
{item.sourceApp}
</span>
)}
</div>
</div>
</div>
</div>
@@ -109,6 +116,11 @@ export function InboxDetailsDialog({
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
{isPending && onProcess && (
<Button
type="button"
@@ -120,11 +132,6 @@ export function InboxDetailsDialog({
Processar
</Button>
)}
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -6,10 +6,20 @@ import {
RiArrowRightDoubleLine,
RiArrowRightSLine,
RiAtLine,
RiCalendarEventLine,
RiDeleteBinLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
bulkDeleteInboxItemsAction,
@@ -42,6 +52,7 @@ import {
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog";
import type {
@@ -52,8 +63,80 @@ import type {
SelectOption,
} from "./types";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
.toISOString()
.slice(0, 10);
}
function getGroupLabel(dateKey: string): string {
const now = new Date();
const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
);
if (dateKey === todayKey) return "Hoje";
if (dateKey === yesterdayKey) return "Ontem";
const [year, month, day] = dateKey.split("-").map(Number);
return format(new Date(year, month - 1, day), "d 'de' MMMM", {
locale: ptBR,
});
}
function groupItemsByDay(
items: InboxItem[],
): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>();
for (const item of items) {
const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
return sortedKeys.map((key) => ({
label: getGroupLabel(key),
items: groups.get(key) ?? [],
}));
}
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
interface InboxPageProps {
activeStatus: InboxStatus;
activeApp: string | null;
sourceApps: string[];
items: InboxItem[];
counts: InboxStatusCounts;
pagination: InboxPaginationState;
@@ -69,6 +152,8 @@ interface InboxPageProps {
export function InboxPage({
activeStatus,
activeApp,
sourceApps = [],
items,
counts,
pagination,
@@ -111,6 +196,38 @@ export function InboxPage({
const [selectionBulkStatus, setSelectionBulkStatus] =
useState<InboxStatus>("pending");
const normalizedSourceApps = useMemo(() => {
if (!Array.isArray(sourceApps)) {
return [];
}
const uniqueApps = new Set<string>();
for (const app of sourceApps) {
if (typeof app !== "string") {
continue;
}
const trimmedApp = app.trim();
if (!trimmedApp) {
continue;
}
uniqueApps.add(trimmedApp);
}
return [...uniqueApps].sort((left, right) =>
left.localeCompare(right, "pt-BR"),
);
}, [sourceApps]);
const appFilterOptions =
activeApp && !normalizedSourceApps.includes(activeApp)
? [activeApp, ...normalizedSourceApps]
: normalizedSourceApps;
const getAppLogo = (appName: string | null) =>
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) {
@@ -132,20 +249,20 @@ export function InboxPage({
}
};
const handleProcessRequest = (item: InboxItem) => {
const handleProcessRequest = useCallback((item: InboxItem) => {
setItemToProcess(item);
setProcessOpen(true);
};
}, []);
const handleDetailsRequest = (item: InboxItem) => {
const handleDetailsRequest = useCallback((item: InboxItem) => {
setItemDetails(item);
setDetailsOpen(true);
};
}, []);
const handleDiscardRequest = (item: InboxItem) => {
const handleDiscardRequest = useCallback((item: InboxItem) => {
setItemToDiscard(item);
setDiscardOpen(true);
};
}, []);
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
@@ -170,10 +287,10 @@ export function InboxPage({
}
};
const handleDeleteRequest = (item: InboxItem) => {
const handleDeleteRequest = useCallback((item: InboxItem) => {
setItemToDelete(item);
setDeleteOpen(true);
};
}, []);
const handleDeleteConfirm = async () => {
if (!itemToDelete) return;
@@ -198,10 +315,10 @@ export function InboxPage({
}
};
const handleRestoreRequest = (item: InboxItem) => {
const handleRestoreRequest = useCallback((item: InboxItem) => {
setItemToRestore(item);
setRestoreOpen(true);
};
}, []);
const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return;
@@ -224,13 +341,13 @@ export function InboxPage({
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [items]);
const toggleSelection = (id: string) => {
const toggleSelection = useCallback((id: string) => {
setSelectedIds((current) =>
current.includes(id)
? current.filter((value) => value !== id)
: [...current, id],
);
};
}, []);
const allSelected = items.length > 0 && selectedIds.length === items.length;
@@ -239,7 +356,6 @@ export function InboxPage({
setSelectedIds([]);
return;
}
setSelectedIds(items.map((item) => item.id));
};
@@ -276,8 +392,42 @@ export function InboxPage({
});
};
const handleAppChange = (nextApp: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (nextApp === "all") {
nextParams.delete("app");
} else {
nextParams.set("app", nextApp);
}
nextParams.delete("page");
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
const handleTabChange = (nextStatus: string) => {
updateUrl(nextStatus as InboxStatus, 1, pagination.pageSize);
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("app");
if (nextStatus === "pending") {
nextParams.delete("status");
} else {
nextParams.set("status", nextStatus);
}
nextParams.delete("page");
if (pagination.pageSize === INBOX_DEFAULT_PAGE_SIZE) {
nextParams.delete("pageSize");
} else {
nextParams.set("pageSize", pagination.pageSize.toString());
}
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
const handleSelectionBulkRequest = (status: InboxStatus) => {
@@ -401,32 +551,105 @@ export function InboxPage({
</Card>
);
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
list.length === 0 ? (
renderEmptyState(
const renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => {
if (list.length === 0) {
if (activeApp) {
return renderEmptyState("Nenhuma notificação deste app");
}
return renderEmptyState(
readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente",
)
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{list.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : handleProcessRequest}
onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={readonly ? handleRestoreRequest : undefined}
selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection}
/>
);
}
const groups = groupItemsByDay(list);
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.label}>
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
<RiCalendarEventLine className="size-3.5 shrink-0" />
<p className="text-sm font-medium">{group.label}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{group.items.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : handleProcessRequest}
onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={
readonly ? handleRestoreRequest : undefined
}
selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection}
/>
))}
</div>
</div>
))}
</div>
);
};
const renderAppFilter = () => {
if (appFilterOptions.length === 0) {
return null;
}
return (
<Select value={activeApp ?? "all"} onValueChange={handleAppChange}>
<SelectTrigger className="w-[190px]">
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<Image
src={activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{activeApp ?? "Todos"}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<Image
src={DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span>Todos</span>
</span>
</SelectItem>
{appFilterOptions.map((app) => (
<SelectItem key={app} value={app}>
<span className="flex min-w-0 items-center gap-2">
<Image
src={getAppLogo(app)}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{app}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<>
@@ -463,80 +686,110 @@ export function InboxPage({
</TabsList>
<TabsContent value="pending" className="mt-4">
{activeStatus === "pending" && items.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedIds.length})
</Button>
)}
</div>
)}
{activeStatus === "pending" ? renderGrid(items, false) : null}
{activeStatus === "pending" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedIds.length})
</Button>
)}
</div>
) : null}
</div>
)}
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
</TabsContent>
<TabsContent value="processed" className="mt-4">
{activeStatus === "processed" && items.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
)}
{activeStatus === "processed" ? renderGrid(items, true) : null}
{activeStatus === "processed" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
</TabsContent>
<TabsContent value="discarded" className="mt-4">
{activeStatus === "discarded" && items.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
)}
{activeStatus === "discarded" ? renderGrid(items, true) : null}
{activeStatus === "discarded" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
</TabsContent>
</Tabs>

View File

@@ -31,6 +31,10 @@ export const resolveInboxStatus = (
: "pending";
};
export const resolveInboxApp = (
params: ResolvedInboxSearchParams,
): string | null => getSingleParam(params, "app");
export const resolveInboxPagination = (
params: ResolvedInboxSearchParams,
): Pick<InboxPaginationState, "page" | "pageSize"> => {

View File

@@ -39,18 +39,26 @@ export async function fetchInboxItemsPage(
{
page,
pageSize,
sourceApp,
}: {
page: number;
pageSize: number;
sourceApp?: string | null;
},
): Promise<{
items: InboxItem[];
pagination: InboxPaginationState;
}> {
const where = and(
eq(inboxItems.userId, userId),
eq(inboxItems.status, status),
sourceApp ? eq(inboxItems.sourceAppName, sourceApp) : undefined,
);
const [countRow] = await db
.select({ total: count() })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
.where(where);
const totalItems = Number(countRow?.total ?? 0);
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
@@ -60,7 +68,7 @@ export async function fetchInboxItemsPage(
const items = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
.where(where)
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
.limit(pageSize)
.offset(offset);
@@ -76,6 +84,21 @@ export async function fetchInboxItemsPage(
};
}
export async function fetchInboxSourceApps(
userId: string,
status: InboxStatus,
): Promise<string[]> {
const rows = await db
.selectDistinct({ name: inboxItems.sourceAppName })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
return rows
.map((row) => row.name)
.filter((name): name is string => name !== null)
.sort();
}
export async function fetchInboxStatusCounts(
userId: string,
): Promise<InboxStatusCounts> {

View File

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

View File

@@ -7,6 +7,7 @@ import {
RiDatabase2Line,
RiDeviceLine,
RiDownloadCloudLine,
RiErrorWarningLine,
RiEyeOffLine,
RiFileTextLine,
RiFlashlightLine,
@@ -34,9 +35,6 @@ export type FeatureItem = {
colorVar: string;
};
export const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export const navLinks = [
{ href: "#telas", label: "conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" },
@@ -260,6 +258,20 @@ export const whoIsItForItems: FeatureItem[] = [
"Você vai precisar configurar as coisas, conectar suas contas e ajustar o sistema para o seu jeito de usar.",
colorVar: "var(--data-4)",
},
{
icon: RiShieldCheckLine,
title: "Não é para qualquer um",
description:
"Não é uma empresa, não é um SaaS, não é uma plataforma. É um projeto pessoal.",
colorVar: "var(--data-1)",
},
{
icon: RiErrorWarningLine,
title: "Não sou responsável por nada",
description:
"Não sou responsável por nada que aconteça com você ou com seus dados.",
colorVar: "var(--data-9)",
},
];
export function getMetricsItems(stars: number, forks: number) {

View File

@@ -113,6 +113,7 @@ export async function fetchTopEstablishmentsData(
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
isNull(transactions.note),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),

View File

@@ -73,7 +73,7 @@ export function PasskeysForm() {
const { data, error: fetchError } =
await authClient.passkey.listUserPasskeys();
if (fetchError) {
setError(fetchError.message || "Erro ao carregar passkeys.");
setError((fetchError.message as string) || "Erro ao carregar passkeys.");
return;
}
setPasskeys(
@@ -111,7 +111,7 @@ export function PasskeysForm() {
name: addName.trim() || undefined,
});
if (addError) {
setError(addError.message || "Erro ao registrar passkey.");
setError((addError.message as string) || "Erro ao registrar passkey.");
return;
}
setAddName("");
@@ -134,7 +134,7 @@ export function PasskeysForm() {
name: editName.trim(),
});
if (renameError) {
setError(renameError.message || "Erro ao renomear passkey.");
setError((renameError.message as string) || "Erro ao renomear passkey.");
return;
}
setEditingId(null);
@@ -156,7 +156,7 @@ export function PasskeysForm() {
id: deleteId,
});
if (deleteError) {
setError(deleteError.message || "Erro ao remover passkey.");
setError((deleteError.message as string) || "Erro ao remover passkey.");
return;
}
setDeleteId(null);

View File

@@ -0,0 +1,435 @@
"use server";
import crypto, { randomUUID } from "node:crypto";
import { and, count, eq, inArray } from "drizzle-orm";
import { z } from "zod/v4";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
} from "@/features/transactions/attachments-config";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
createPresignedGetUrl,
createPresignedPutUrl,
deleteS3Object,
headS3Object,
} from "@/shared/lib/storage/presign";
import type { ActionResult } from "@/shared/lib/types/actions";
const UPLOAD_TOKEN_EXPIRY_SECONDS = 10 * 60;
const presignSchema = z.object({
fileName: z.string().min(1),
mimeType: z.enum(ALLOWED_MIME_TYPES),
fileSize: z.number().max(MAX_FILE_SIZE, "Arquivo deve ter no máximo 50MB."),
transactionId: z.string().uuid(),
});
const confirmSchema = z.object({
uploadToken: z.string().min(1),
applyToSeries: z.boolean().default(false),
});
const detachSchema = z.object({
attachmentId: z.string().uuid(),
transactionId: z.string().uuid(),
});
type PresignResult =
| {
success: true;
presignedUrl: string;
fileKey: string;
uploadToken: string;
}
| { success: false; error: string };
type UploadTokenPayload = {
userId: string;
transactionId: string;
fileKey: string;
fileName: string;
mimeType: (typeof ALLOWED_MIME_TYPES)[number];
fileSize: number;
exp: number;
};
function getUploadTokenSecret(): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error(
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
);
}
return secret;
}
function base64UrlEncode(value: string): string {
return Buffer.from(value)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function base64UrlDecode(value: string): string {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const pad = normalized.length % 4;
const padded = pad ? normalized + "=".repeat(4 - pad) : normalized;
return Buffer.from(padded, "base64").toString("utf8");
}
function signUploadToken(payload: UploadTokenPayload): string {
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signature = crypto
.createHmac("sha256", getUploadTokenSecret())
.update(encodedPayload)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `${encodedPayload}.${signature}`;
}
function verifyUploadToken(token: string): UploadTokenPayload | null {
try {
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) return null;
const expectedSignature = crypto
.createHmac("sha256", getUploadTokenSecret())
.update(encodedPayload)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload = JSON.parse(
base64UrlDecode(encodedPayload),
) as UploadTokenPayload;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (!payload.fileKey.startsWith(`${payload.userId}/`)) return null;
if (!ALLOWED_MIME_TYPES.includes(payload.mimeType)) return null;
if (payload.fileSize <= 0 || payload.fileSize > MAX_FILE_SIZE) return null;
return payload;
} catch {
return null;
}
}
export async function getPresignedUploadUrlAction(input: {
fileName: string;
mimeType: string;
fileSize: number;
transactionId: string;
}): Promise<PresignResult> {
try {
const user = await getUser();
const data = presignSchema.parse(input);
const [transaction] = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(
eq(transactions.id, data.transactionId),
eq(transactions.userId, user.id),
),
);
if (!transaction) {
return { success: false, error: "Lançamento não encontrado." };
}
const ext = data.fileName.split(".").pop()?.toLowerCase() ?? "bin";
const fileKey = `${user.id}/${randomUUID()}.${ext}`;
const presignedUrl = await createPresignedPutUrl(fileKey, data.mimeType);
const uploadToken = signUploadToken({
userId: user.id,
transactionId: data.transactionId,
fileKey,
fileName: data.fileName,
mimeType: data.mimeType,
fileSize: data.fileSize,
exp: Math.floor(Date.now() / 1000) + UPLOAD_TOKEN_EXPIRY_SECONDS,
});
return { success: true, presignedUrl, fileKey, uploadToken };
} catch (error) {
const result = handleActionError(error);
if (!result.success) return { success: false, error: result.error };
return { success: false, error: "Erro inesperado." };
}
}
export async function confirmAttachmentUploadAction(input: {
uploadToken: string;
applyToSeries?: boolean;
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = confirmSchema.parse(input);
const uploadPayload = verifyUploadToken(data.uploadToken);
if (!uploadPayload || uploadPayload.userId !== user.id) {
return { success: false, error: "Upload de anexo inválido ou expirado." };
}
const [transaction] = await db
.select({ id: transactions.id, seriesId: transactions.seriesId })
.from(transactions)
.where(
and(
eq(transactions.id, uploadPayload.transactionId),
eq(transactions.userId, user.id),
),
);
if (!transaction) {
return { success: false, error: "Lançamento não encontrado." };
}
const objectMetadata = await headS3Object(uploadPayload.fileKey);
if (!objectMetadata.contentLength || objectMetadata.contentLength <= 0) {
return { success: false, error: "Arquivo enviado não encontrado." };
}
if (objectMetadata.contentLength > MAX_FILE_SIZE) {
return {
success: false,
error: "O arquivo enviado excede o limite permitido de 50MB.",
};
}
if (objectMetadata.contentLength !== uploadPayload.fileSize) {
return {
success: false,
error:
"O tamanho do arquivo enviado não confere com o upload autorizado.",
};
}
if (objectMetadata.contentType !== uploadPayload.mimeType) {
return {
success: false,
error: "O tipo do arquivo enviado não confere com o upload autorizado.",
};
}
const [attachment] = await db
.insert(attachments)
.values({
userId: user.id,
fileKey: uploadPayload.fileKey,
fileName: uploadPayload.fileName,
fileSize: uploadPayload.fileSize,
mimeType: uploadPayload.mimeType,
})
.returning({ id: attachments.id });
if (!attachment) {
return { success: false, error: "Erro ao salvar o anexo." };
}
let transactionIds: string[] = [uploadPayload.transactionId];
if (data.applyToSeries && transaction.seriesId) {
const seriesRows = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(
eq(transactions.seriesId, transaction.seriesId),
eq(transactions.userId, user.id),
),
);
transactionIds = seriesRows.map((t) => t.id);
}
await db.insert(transactionAttachments).values(
transactionIds.map((tid) => ({
transactionId: tid,
attachmentId: attachment.id,
})),
);
revalidateForEntity("transactions", user.id);
return { success: true, message: "Anexo salvo com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function detachTransactionAttachmentAction(input: {
attachmentId: string;
transactionId: string;
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = detachSchema.parse(input);
const [transaction] = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(
eq(transactions.id, data.transactionId),
eq(transactions.userId, user.id),
),
);
if (!transaction) {
return { success: false, error: "Lançamento não encontrado." };
}
const [attachment] = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(attachments)
.where(
and(
eq(attachments.id, data.attachmentId),
eq(attachments.userId, user.id),
),
);
if (!attachment) {
return { success: false, error: "Anexo não encontrado." };
}
await db
.delete(transactionAttachments)
.where(
and(
eq(transactionAttachments.transactionId, data.transactionId),
eq(transactionAttachments.attachmentId, data.attachmentId),
),
);
const [remaining] = await db
.select({ total: count() })
.from(transactionAttachments)
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
if (!remaining || remaining.total === 0) {
await deleteS3Object(attachment.fileKey);
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
}
revalidateForEntity("transactions", user.id);
return { success: true, message: "Anexo removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function fetchTransactionAttachmentsAction(
transactionId: string,
): Promise<
Array<{
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: Date;
url: string;
}>
> {
const user = await getUser();
const [transaction] = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(eq(transactions.id, transactionId), eq(transactions.userId, user.id)),
);
if (!transaction) {
return [];
}
const rows = await db
.select({
attachmentId: transactionAttachments.attachmentId,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
fileKey: attachments.fileKey,
createdAt: attachments.createdAt,
})
.from(transactionAttachments)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, user.id),
),
)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, user.id),
),
)
.where(eq(transactionAttachments.transactionId, transactionId));
return Promise.all(
rows.map(async (row) => ({
attachmentId: row.attachmentId,
fileName: row.fileName,
fileSize: row.fileSize,
mimeType: row.mimeType,
createdAt: row.createdAt,
url: await createPresignedGetUrl(row.fileKey),
})),
);
}
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
export async function cleanupAttachmentsAfterTransactionDelete(
attachmentData: Array<{ id: string; fileKey: string }>,
): Promise<void> {
if (attachmentData.length === 0) return;
const uniqueIds = [...new Set(attachmentData.map((a) => a.id))];
const remaining = await db
.select({
attachmentId: transactionAttachments.attachmentId,
total: count(),
})
.from(transactionAttachments)
.where(inArray(transactionAttachments.attachmentId, uniqueIds))
.groupBy(transactionAttachments.attachmentId);
const remainingMap = new Map(remaining.map((r) => [r.attachmentId, r.total]));
for (const att of attachmentData) {
if (!remainingMap.has(att.id) || (remainingMap.get(att.id) ?? 0) === 0) {
await deleteS3Object(att.fileKey);
await db.delete(attachments).where(eq(attachments.id, att.id));
}
}
}

View File

@@ -172,6 +172,7 @@ export async function updateTransactionBulkAction(
payerId: data.payerId ?? null,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
...(data.isSettled !== undefined && { isSettled: data.isSettled }),
};
if (data.amount !== undefined) {

View File

@@ -6,7 +6,6 @@ import { normalizeDescriptionKey } from "@/features/transactions/lib/import-util
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
// Retorna um map de descriptionKey → categoryId para as descrições fornecidas
export async function fetchCategoryMappings(
descriptions: string[],
@@ -53,7 +52,10 @@ export async function saveCategoryMappings(
.insert(importCategoryMappings)
.values(toUpsert)
.onConflictDoUpdate({
target: [importCategoryMappings.userId, importCategoryMappings.descriptionKey],
target: [
importCategoryMappings.userId,
importCategoryMappings.descriptionKey,
],
set: {
categoryId: sql`excluded.category_id`,
updatedAt: sql`excluded.updated_at`,

View File

@@ -705,6 +705,7 @@ export const updateBulkSchema = z.object({
})
.optional()
.nullable(),
isSettled: z.boolean().nullable().optional(),
});
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;

View File

@@ -29,7 +29,11 @@ const importSchema = z.object({
accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(),
paymentMethod: z.string().min(1),
invoicePeriod: z.string().regex(/^\d{4}-\d{2}$/, "Período inválido.").nullable().optional(),
invoicePeriod: z
.string()
.regex(/^\d{4}-\d{2}$/, "Período inválido.")
.nullable()
.optional(),
});
export type ImportRow = z.infer<typeof importRowSchema>;
@@ -51,10 +55,7 @@ export async function checkDuplicateFitIds(
.select({ ofxFitId: transactions.ofxFitId })
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.ofxFitId, ids),
),
and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
);
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
@@ -67,10 +68,14 @@ export async function importTransactionsAction(
const parsed = importSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message ?? "Dados inválidos." };
return {
success: false,
error: parsed.error.issues[0]?.message ?? "Dados inválidos.",
};
}
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } = parsed.data;
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
// Valida ownership
const [payerOk, accountOk, cardOk] = await Promise.all([
@@ -94,14 +99,19 @@ export async function importTransactionsAction(
const records = rows.map((row) => {
const purchaseDate = parseLocalDateString(row.date);
const period = invoicePeriod ?? `${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
const period =
invoicePeriod ??
`${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
return {
name: row.description,
transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
condition: "À vista" as const,
paymentMethod,
amount: (row.transactionType === "expense" ? -row.amount : row.amount).toFixed(2),
amount: (row.transactionType === "expense"
? -row.amount
: row.amount
).toFixed(2),
purchaseDate,
period,
isSettled,
@@ -143,10 +153,7 @@ export async function deleteTransactionByFitId(
await db
.delete(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.ofxFitId, fitId),
),
and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
);
await revalidateForEntity("transactions", userId);

View File

@@ -1,11 +1,18 @@
"use server";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import { and, eq, inArray } from "drizzle-orm";
import {
attachments,
financialAccounts,
invoices,
transactionAttachments,
transactions,
} from "@/db/schema";
import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import {
buildEntriesByPayer,
sendPayerAutoEmails,
@@ -16,6 +23,8 @@ import {
getBusinessTodayDate,
parseLocalDateString,
} from "@/shared/utils/date";
import { MONTH_NAMES } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import {
buildLancamentoRecords,
buildShares,
@@ -37,7 +46,7 @@ import {
export async function createTransactionAction(
input: CreateInput,
): Promise<ActionResult> {
): Promise<ActionResult<{ ids: string[] }>> {
try {
const user = await getUser();
const data = createSchema.parse(input);
@@ -102,7 +111,42 @@ export async function createTransactionAction(
throw new Error("Não foi possível criar os lançamentos solicitados.");
}
await db.insert(transactions).values(records);
if (data.cardId) {
const uniquePeriods = [
...new Set(
records.map((r) => r.period).filter((p): p is string => Boolean(p)),
),
];
const paidInvoices = await db.query.invoices.findMany({
columns: { period: true },
where: and(
eq(invoices.userId, user.id),
eq(invoices.cardId, data.cardId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
inArray(invoices.period, uniquePeriods),
),
});
if (paidInvoices.length > 0) {
const labels = paidInvoices
.map((inv) => {
const [year, month] = (inv.period ?? "").split("-");
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
return `${monthName}/${year}`;
})
.join(", ");
return {
success: false,
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
} as ActionResult<{ ids: string[] }>;
}
}
const inserted = await db
.insert(transactions)
.values(records)
.returning({ id: transactions.id });
const notificationEntries = buildEntriesByPayer(
records.map((record) => ({
@@ -128,9 +172,13 @@ export async function createTransactionAction(
revalidate(user.id);
return { success: true, message: "Lançamento criado com sucesso." };
return {
success: true,
message: "Lançamento criado com sucesso.",
data: { ids: inserted.map((r) => r.id) },
};
} catch (error) {
return handleActionError(error);
return handleActionError(error) as ActionResult<{ ids: string[] }>;
}
}
@@ -329,12 +377,23 @@ export async function deleteTransactionAction(
};
}
const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(eq(transactionAttachments.transactionId, data.id));
await db
.delete(transactions)
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
if (existing.payerId) {
const notificationEntries = buildEntriesByPayer([
{

View File

@@ -0,0 +1,8 @@
export const ALLOWED_MIME_TYPES = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB

View File

@@ -0,0 +1,92 @@
"use client";
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
import { useRef } from "react";
import { toast } from "sonner";
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
} from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps {
file: File | null;
onChange: (file: File | null) => void;
}
export function AttachmentFilePicker({
file,
onChange,
}: AttachmentFilePickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0];
if (inputRef.current) inputRef.current.value = "";
if (!selected) return;
if (
!ALLOWED_MIME_TYPES.includes(
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
toast.error(
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
);
return;
}
if (selected.size > MAX_FILE_SIZE) {
toast.error("O arquivo deve ter no máximo 50MB.");
return;
}
onChange(selected);
}
return (
<div className="space-y-1.5">
<p className="text-xs">Anexo</p>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ALLOWED_MIME_TYPES.join(",")}
onChange={handleFileChange}
/>
{file ? (
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={file.name}>
{file.name}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={() => onChange(null)}
>
<RiCloseLine className="size-4" />
</Button>
</div>
) : (
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-[11px]">
PDF, JPEG, PNG ou WebP · máx. 50 MB
</span>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import {
RiDeleteBinLine,
RiDownloadLine,
RiExternalLinkLine,
RiFileImageLine,
RiFileLine,
RiFilePdfLine,
} from "@remixicon/react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { detachTransactionAttachmentAction } from "@/features/transactions/actions/attachments";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function AttachmentIcon({ mimeType }: { mimeType: string }) {
if (mimeType === "application/pdf")
return <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
if (mimeType.startsWith("image/"))
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
}
function AttachmentPreview({
open,
onOpenChange,
fileName,
mimeType,
url,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
mimeType: string;
url: string;
}) {
const isPdf = mimeType === "application/pdf";
const isImage = mimeType.startsWith("image/");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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-center justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="min-w-0">
<DialogTitle
className="truncate text-sm font-medium"
title={fileName}
>
{fileName}
</DialogTitle>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon" asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
download={fileName}
>
<RiDownloadLine className="size-4" />
</a>
</Button>
<Button type="button" variant="ghost" size="icon" asChild>
<a href={url} target="_blank" rel="noreferrer">
<RiExternalLinkLine className="size-4" />
</a>
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Fechar
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{isPdf && (
<iframe
src={url}
className="h-full w-full border-0 bg-background"
title={fileName}
/>
)}
{isImage && (
<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
src={url}
alt={fileName}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
interface AttachmentItemProps {
attachmentId: string;
transactionId: string;
fileName: string;
fileSize: number;
mimeType: string;
url: string;
onDeleted: () => void;
readonly?: boolean;
}
export function AttachmentItem({
attachmentId,
transactionId,
fileName,
fileSize,
mimeType,
url,
onDeleted,
readonly = false,
}: AttachmentItemProps) {
const [isPending, startTransition] = useTransition();
const [previewOpen, setPreviewOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const canPreview =
mimeType === "application/pdf" || mimeType.startsWith("image/");
function handleDelete() {
startTransition(async () => {
const result = await detachTransactionAttachmentAction({
attachmentId,
transactionId,
});
if (result.success) {
toast.success(result.message);
onDeleted();
} else {
toast.error(result.error);
}
});
setConfirmOpen(false);
}
return (
<>
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
<AttachmentIcon mimeType={mimeType} />
{canPreview ? (
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => setPreviewOpen(true)}
title={fileName}
>
<p className="truncate font-medium hover:underline">{fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(fileSize)}
</p>
</button>
) : (
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(fileSize)}
</p>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
asChild
>
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button>
{!readonly && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
>
<RiDeleteBinLine className="size-4" />
</Button>
)}
</div>
{canPreview && (
<AttachmentPreview
open={previewOpen}
onOpenChange={setPreviewOpen}
fileName={fileName}
mimeType={mimeType}
url={url}
/>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza que deseja remover{" "}
<span className="break-all font-medium text-foreground">
{fileName}
</span>
?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
Cancelar
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? "Removendo..." : "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
import { AttachmentItem } from "./attachment-item";
import { AttachmentUpload } from "./attachment-upload";
type AttachmentRow = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: Date;
url: string;
};
interface AttachmentSectionProps {
transactionId: string;
seriesId: string | null;
readonly?: boolean;
onLoaded?: (count: number) => void;
}
export function AttachmentSection({
transactionId,
seriesId,
readonly = false,
onLoaded,
}: AttachmentSectionProps) {
const [items, setItems] = useState<AttachmentRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchTransactionAttachmentsAction(transactionId);
setItems(data);
onLoaded?.(data.length);
} finally {
setIsLoading(false);
}
}, [transactionId, onLoaded]);
useEffect(() => {
load();
}, [load]);
return (
<div className="min-w-0 space-y-2 overflow-hidden">
{isLoading ? (
<p className="text-xs text-muted-foreground">Carregando...</p>
) : (
<>
{items.length > 0 ? (
<div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId}
fileName={item.fileName}
fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
/>
))}
</div>
) : (
readonly && (
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
)
)}
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
seriesId={seriesId}
onUploaded={load}
/>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { RiAttachment2 } from "@remixicon/react";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
confirmAttachmentUploadAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
} from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { Label } from "@/shared/components/ui/label";
interface AttachmentUploadProps {
transactionId: string;
seriesId: string | null;
onUploaded: () => void;
}
export function AttachmentUpload({
transactionId,
seriesId,
onUploaded,
}: AttachmentUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
const [applyToSeries, setApplyToSeries] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!inputRef.current) return;
inputRef.current.value = "";
if (!file) return;
if (
!ALLOWED_MIME_TYPES.includes(
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
toast.error(
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
);
return;
}
if (file.size > MAX_FILE_SIZE) {
toast.error("O arquivo deve ter no máximo 50MB.");
return;
}
if (seriesId) {
setPendingFile(file);
} else {
uploadFile(file, false);
}
}
function uploadFile(file: File, toSeries: boolean) {
startTransition(async () => {
const presignResult = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId,
});
if (!presignResult.success) {
toast.error(presignResult.error ?? "Erro ao iniciar upload.");
return;
}
const uploadResponse = await fetch(presignResult.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!uploadResponse.ok) {
toast.error("Erro ao enviar o arquivo. Tente novamente.");
return;
}
const confirmResult = await confirmAttachmentUploadAction({
uploadToken: presignResult.uploadToken,
applyToSeries: toSeries,
});
if (confirmResult.success) {
toast.success(confirmResult.message);
setPendingFile(null);
setApplyToSeries(false);
onUploaded();
} else {
toast.error(confirmResult.error);
}
});
}
function handleConfirmPending() {
if (pendingFile) uploadFile(pendingFile, applyToSeries);
}
function handleCancelPending() {
setPendingFile(null);
setApplyToSeries(false);
}
if (pendingFile) {
return (
<div className="min-w-0 space-y-2 rounded-md border border-dashed p-3 text-sm">
<div className="min-w-0 overflow-hidden">
<p className="truncate font-medium" title={pendingFile.name}>
{pendingFile.name}
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="apply-series"
checked={applyToSeries}
onCheckedChange={(v) => setApplyToSeries(Boolean(v))}
/>
<Label htmlFor="apply-series" className="cursor-pointer text-xs">
Aplicar a todas as parcelas da série
</Label>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={handleConfirmPending}
disabled={isPending}
>
{isPending ? "Enviando..." : "Confirmar"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancelPending}
disabled={isPending}
>
Cancelar
</Button>
</div>
</div>
);
}
return (
<>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ALLOWED_MIME_TYPES.join(",")}
onChange={handleFileChange}
/>
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
onClick={() => inputRef.current?.click()}
disabled={isPending}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
{isPending ? "Enviando..." : "Adicionar anexo"}
</span>
{!isPending && (
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span>
)}
</button>
</>
);
}

View File

@@ -38,6 +38,7 @@ import {
import { Separator } from "@/shared/components/ui/separator";
import { Spinner } from "@/shared/components/ui/spinner";
import { getTodayDateString } from "@/shared/utils/date";
import { createClientSafeId } from "@/shared/utils/id";
import {
dateToPeriod,
displayPeriod,
@@ -120,6 +121,19 @@ interface TransactionRow {
payerId: string | undefined;
}
function createEmptyTransactionRow(
defaultPayerId?: string | null,
): TransactionRow {
return {
id: createClientSafeId(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
};
}
export function MassAddDialog({
open,
onOpenChange,
@@ -153,15 +167,8 @@ export function MassAddDialog({
const isCartaoSelected = paymentMethod === "Cartão de crédito";
// Transaction rows
const [transactions, setTransactions] = useState<TransactionRow[]>([
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
},
const [transactions, setTransactions] = useState<TransactionRow[]>(() => [
createEmptyTransactionRow(defaultPayerId),
]);
// Categorias agrupadas e filtradas por tipo de transação
@@ -175,14 +182,7 @@ export function MassAddDialog({
const addTransaction = () => {
setTransactions([
...transactions,
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
},
createEmptyTransactionRow(defaultPayerId),
]);
};
@@ -256,16 +256,7 @@ export function MassAddDialog({
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(defaultCardId ?? undefined);
setTransactions([
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
},
]);
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
} catch (_error) {
// Error is handled by the onSubmit function
} finally {

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import {
currencyFormatter,
formatCondition,
@@ -21,6 +22,7 @@ import {
import { Separator } from "@/shared/components/ui/separator";
import { parseLocalDateString } from "@/shared/utils/date";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
import { AttachmentSection } from "../attachments/attachment-section";
import { InstallmentTimeline } from "../shared/installment-timeline";
import type { TransactionItem } from "../types";
@@ -37,6 +39,12 @@ export function TransactionDetailsDialog({
transaction,
onEdit,
}: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
useEffect(() => {
setAttachmentCount(null);
}, [transaction?.id]);
if (!transaction) return null;
const isInstallment =
@@ -63,7 +71,7 @@ export function TransactionDetailsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
<DialogHeader>
<DialogTitle>{transaction.name}</DialogTitle>
<DialogDescription>
@@ -71,57 +79,18 @@ export function TransactionDetailsDialog({
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto text-sm">
<div className="grid gap-3">
<ul className="grid gap-3">
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li>
<DetailRow
label={transaction.cartaoName ? "Cartão" : "Conta"}
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Tipo de Transação</span>
<TransactionTypeBadge
kind={
transaction.categoriaName === "Saldo inicial"
? "Saldo inicial"
: transaction.transactionType
}
/>
</li>
<DetailRow
label="Condição"
value={formatCondition(transaction.condition)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Status</span>
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
<div className="min-w-0 space-y-4">
<section className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Resumo
</p>
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(valorTotal)}
</p>
</div>
<Badge
variant="secondary"
className={
@@ -132,75 +101,140 @@ export function TransactionDetailsDialog({
>
{transaction.isSettled ? "Pago" : "Pendente"}
</Badge>
</li>
{isBoleto && transaction.dueDate && (
<DetailRow
label="Vencimento"
value={formatDate(transaction.dueDate)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TransactionTypeBadge
kind={
transaction.categoriaName === "Saldo inicial"
? "Saldo inicial"
: transaction.transactionType
}
/>
<span>{formatCondition(transaction.condition)}</span>
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detalhes
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
/>
)}
{transaction.isDivided && (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Divisão</span>
<Badge variant="outline">Dividido</Badge>
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li>
)}
{transaction.note && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">Notas</span>
<span className="text-foreground">{transaction.note}</span>
<DetailRow
label={transaction.cartaoName ? "Cartão" : "Conta"}
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li>
)}
</ul>
<ul className="mb-2 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
{isBoleto && transaction.dueDate && (
<DetailRow
label="Vencimento"
value={formatDate(transaction.dueDate)}
/>
</li>
)}
)}
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
{transaction.isDivided && (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Divisão</span>
<Badge variant="outline">Dividido</Badge>
</li>
)}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Valores
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
{isInstallment && (
<li className="mb-1">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
/>
</li>
)}
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
)}
{transaction.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${transaction.recurrenceCount} meses`}
/>
)}
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{!isInstallment && <Separator className="my-2" />}
{transaction.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${transaction.recurrenceCount} meses`}
/>
)}
</ul>
</section>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">Total da Compra</span>
<span className="text-lg">
{currencyFormatter.format(valorTotal)}
</span>
</li>
</ul>
{transaction.note ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Notas
</h3>
<div className="rounded-lg border p-3 text-foreground">
{transaction.note}
</div>
</section>
) : null}
{attachmentCount !== 0 && (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Anexos
</h3>
<div className="min-w-0">
<AttachmentSection
transactionId={transaction.id}
seriesId={transaction.seriesId}
readonly
onLoaded={setAttachmentCount}
/>
</div>
</section>
)}
</div>
</div>
<Separator />
<DialogFooter>
{onEdit && !transaction.readonly && (
<Button variant="outline" onClick={handleEdit}>
@@ -223,9 +257,9 @@ interface DetailRowProps {
function DetailRow({ label, value }: DetailRowProps) {
return (
<li className="flex items-center justify-between">
<li className="min-w-0 flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
<span>{value}</span>
<span className="min-w-0 truncate">{value}</span>
</li>
);
}

View File

@@ -41,6 +41,7 @@ export interface TransactionDialogProps {
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean | null;
}) => void;
}

View File

@@ -6,6 +6,10 @@ import {
createTransactionAction,
updateTransactionAction,
} from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import {
filterSecondaryPayerOptions,
groupAndSortCategories,
@@ -30,7 +34,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { AttachmentFilePicker } from "../../attachments/attachment-file-picker";
import { AttachmentSection } from "../../attachments/attachment-section";
import { BasicFieldsSection } from "./basic-fields-section";
import { BoletoFieldsSection } from "./boleto-fields-section";
import { CategorySection } from "./category-section";
@@ -90,6 +97,7 @@ export function TransactionDialog({
);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
useEffect(() => {
if (dialogOpen) {
@@ -108,8 +116,9 @@ export function TransactionDialog({
},
);
// Derive credit card period on open when cardId is pre-filled
// Derive credit card period on open when cardId is pre-filled (create only)
if (
mode !== "update" &&
initial.paymentMethod === "Cartão de crédito" &&
initial.cardId &&
initial.purchaseDate
@@ -126,6 +135,7 @@ export function TransactionDialog({
setFormState(initial);
setErrorMessage(null);
setPendingFile(null);
}
}, [
dialogOpen,
@@ -313,6 +323,29 @@ export function TransactionDialog({
const result = await createTransactionAction(payload);
if (result.success) {
if (pendingFile && result.data?.ids?.length) {
const firstId = result.data.ids[0];
const isNewSeries =
formState.condition === "Parcelado" ||
formState.condition === "Recorrente";
const presign = await getPresignedUploadUrlAction({
fileName: pendingFile.name,
mimeType: pendingFile.type,
fileSize: pendingFile.size,
transactionId: firstId,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: pendingFile,
headers: { "Content-Type": pendingFile.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
applyToSeries: isNewSeries,
});
}
}
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
@@ -346,6 +379,10 @@ export function TransactionDialog({
mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null
: null,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
});
return;
}
@@ -411,18 +448,18 @@ export function TransactionDialog({
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogContent className="min-w-0 overflow-x-hidden">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-0"
className="flex min-w-0 flex-col gap-0"
onSubmit={handleSubmit}
noValidate
>
<div className="space-y-3 -mx-6 max-h-[70vh] overflow-y-auto px-6 pb-1">
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
<BasicFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
@@ -473,17 +510,31 @@ export function TransactionDialog({
) : null}
{isUpdateMode ? (
<NoteSection
formState={formState}
onFieldChange={handleFieldChange}
/>
<>
<NoteSection
formState={formState}
onFieldChange={handleFieldChange}
/>
<div className="space-y-2">
<Label className="text-xs font-medium leading-none">
Anexos
</Label>
<AttachmentSection
transactionId={transaction?.id ?? ""}
seriesId={transaction?.seriesId ?? null}
/>
</div>
</>
) : (
<Collapsible defaultOpen={formState.condition !== "À vista"}>
<Collapsible
defaultOpen={formState.condition !== "À vista"}
className="min-w-0"
>
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
Condições e anotações
Condições, anotações e anexos
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
<ConditionSection
formState={formState}
onFieldChange={handleFieldChange}
@@ -494,6 +545,10 @@ export function TransactionDialog({
formState={formState}
onFieldChange={handleFieldChange}
/>
<AttachmentFilePicker
file={pendingFile}
onChange={setPendingFile}
/>
</CollapsibleContent>
</Collapsible>
)}

View File

@@ -33,7 +33,8 @@ export function decodeAccountCard(value: string): {
id: string;
} | null {
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) };
if (value.startsWith("account:")) return { type: "account", id: value.slice(8) };
if (value.startsWith("account:"))
return { type: "account", id: value.slice(8) };
return null;
}
@@ -65,7 +66,9 @@ export function GlobalFields({
onBulkCategoryChange,
}: GlobalFieldsProps) {
const isCard = accountCardValue?.startsWith("card:") ?? false;
const expenseCategories = categoryOptions.filter((o) => o.group === "despesa");
const expenseCategories = categoryOptions.filter(
(o) => o.group === "despesa",
);
const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
return (
@@ -131,7 +134,10 @@ export function GlobalFields({
<SelectContent>
{payerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<PayerSelectContent label={opt.label} avatarUrl={opt.avatarUrl} />
<PayerSelectContent
label={opt.label}
avatarUrl={opt.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
@@ -150,7 +156,10 @@ export function GlobalFields({
<SelectLabel>Despesa</SelectLabel>
{expenseCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} />
<CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem>
))}
</SelectGroup>
@@ -163,7 +172,10 @@ export function GlobalFields({
<SelectLabel>Receita</SelectLabel>
{incomeCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} />
<CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem>
))}
</SelectGroup>
@@ -172,17 +184,17 @@ export function GlobalFields({
</Select>
</div>
{isCard && (
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Fatura</Label>
<PeriodPicker
value={invoicePeriod ?? ""}
onChange={(v) => onInvoicePeriodChange(v || null)}
placeholder="Selecionar fatura…"
/>
</div>
)}
{isCard && (
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Fatura</Label>
<PeriodPicker
value={invoicePeriod ?? ""}
onChange={(v) => onInvoicePeriodChange(v || null)}
placeholder="Selecionar fatura…"
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,18 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
fetchCategoryMappings,
saveCategoryMappings,
} from "@/features/transactions/actions/category-memory-action";
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
import {
checkDuplicateFitIds,
deleteTransactionByFitId,
@@ -27,6 +32,7 @@ import {
} from "@/features/transactions/components/import/review-table";
import { UploadZone } from "@/features/transactions/components/import/upload-zone";
import type { SelectOption } from "@/features/transactions/components/types";
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
import { Button } from "@/shared/components/ui/button";
import {
Card,
@@ -82,7 +88,8 @@ export function ImportPage({
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
categoryId: categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
categoryId:
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
})),
);
} finally {
@@ -167,7 +174,9 @@ export function ImportPage({
const handleImport = () => {
if (!statement || !canImport) return;
const decoded = decodeAccountCard(accountCardValue!);
const decoded = accountCardValue
? decodeAccountCard(accountCardValue)
: null;
const cardId = decoded?.type === "card" ? decoded.id : null;
const accountId = decoded?.type === "account" ? decoded.id : null;
const paymentMethod =
@@ -197,7 +206,10 @@ export function ImportPage({
// Salva mapeamentos description → category (fire-and-forget)
saveCategoryMappings(
selectedRows.map((r) => ({ description: r.description, categoryId: r.categoryId })),
selectedRows.map((r) => ({
description: r.description,
categoryId: r.categoryId,
})),
);
const { importBatchId } = result;
@@ -236,7 +248,8 @@ export function ImportPage({
<div>
<CardTitle>Importar extrato</CardTitle>
<CardDescription>
Importe transações a partir de um arquivo .ofx ou planilha .xlsx exportado pelo seu banco.
Importe transações a partir de um arquivo .ofx ou planilha .xlsx
exportado pelo seu banco.
</CardDescription>
</div>
<ImportSteps current={currentStep} />

View File

@@ -34,7 +34,9 @@ export function ImportSteps({ current }: ImportStepsProps) {
isCompleted &&
"border-primary bg-primary text-primary-foreground",
isActive && "border-primary text-primary",
!isCompleted && !isActive && "border-muted-foreground/30 text-muted-foreground",
!isCompleted &&
!isActive &&
"border-muted-foreground/30 text-muted-foreground",
)}
>
{isCompleted ? (

View File

@@ -1,7 +1,7 @@
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { CategorySelectContent } from "@/features/transactions/components/select-items";
import type { SelectOption } from "@/features/transactions/components/types";
import MoneyValues from "@/shared/components/money-values";
@@ -91,9 +91,7 @@ export function ReviewTable({
onCheckedChange={(v) => onToggleAll(!!v)}
aria-label="Selecionar todas"
data-state={
!allSelected && someSelected
? "indeterminate"
: undefined
!allSelected && someSelected ? "indeterminate" : undefined
}
/>
</TableHead>
@@ -114,7 +112,10 @@ export function ReviewTable({
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]!;
const row = rows[virtualRow.index];
if (!row) {
return null;
}
const index = virtualRow.index;
return (
<TableRow
@@ -199,9 +200,7 @@ export function ReviewTable({
<TableCell>
<TransactionTypeBadge
kind={
row.transactionType === "income"
? "Receita"
: "Despesa"
row.transactionType === "income" ? "Receita" : "Despesa"
}
/>
</TableCell>

View File

@@ -37,7 +37,9 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
}
onParsed(statement);
} catch {
setError("Não foi possível ler o arquivo. Verifique se é um OFX válido.");
setError(
"Não foi possível ler o arquivo. Verifique se é um OFX válido.",
);
}
};
reader.readAsText(file, "windows-1252");
@@ -119,11 +121,7 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
/>
<div className="flex items-center justify-between">
{error ? (
<p className="text-destructive text-sm">{error}</p>
) : (
<span />
)}
{error ? <p className="text-destructive text-sm">{error}</p> : <span />}
<button
type="button"
onClick={handleDownloadTemplate}

View File

@@ -129,6 +129,7 @@ export function TransactionsPage({
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean | null;
transaction: TransactionItem;
} | null>(null);
const [pendingDeleteData, setPendingDeleteData] =
@@ -182,7 +183,7 @@ export function TransactionsPage({
toast.success(
nextValue
? `"${item.name}" marcado como pago`
: `"${item.name}" desmarcado`,
: `"${item.name}" marcado como não pago`,
);
} catch (error) {
const message =
@@ -244,6 +245,7 @@ export function TransactionsPage({
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean | null;
}) => {
if (!selectedTransaction) {
return;
@@ -274,6 +276,7 @@ export function TransactionsPage({
amount: pendingEditData.amount,
dueDate: pendingEditData.dueDate,
boletoPaymentDate: pendingEditData.boletoPaymentDate,
isSettled: pendingEditData.isSettled ?? undefined,
});
if (!result.success) {
@@ -572,7 +575,7 @@ export function TransactionsPage({
onConfirm={handleBulkEdit}
/>
{allowCreate ? (
{allowCreate && massAddOpen ? (
<MassAddDialog
open={massAddOpen}
onOpenChange={setMassAddOpen}

View File

@@ -6,6 +6,7 @@ import {
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
RiAttachment2,
RiBankCard2Line,
RiChat1Line,
RiCheckboxBlankCircleLine,
@@ -13,9 +14,9 @@ import {
RiCheckLine,
RiDeleteBin5Line,
RiFileCopyLine,
RiFileExcel2Line,
RiFileList2Line,
RiFlashlightFill,
RiFileExcel2Line,
RiGroupLine,
RiHistoryLine,
RiMoreFill,
@@ -115,6 +116,14 @@ type BuildColumnsArgs = {
showActions: boolean;
};
function getPaymentMethodTableLabel(method: string) {
if (method === "Transferência bancária") {
return "Transf. bancária";
}
return method;
}
const buildColumns = ({
currentUserId,
noteAsColumn,
@@ -182,6 +191,7 @@ const buildColumns = ({
note,
isDivided,
isAnticipated,
hasAttachments,
} = row.original;
const installmentBadge =
@@ -191,7 +201,7 @@ const buildColumns = ({
const isBoleto = paymentMethod === "Boleto" && dueDate;
const dueDateLabel =
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null;
isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
const hasNote = Boolean(note?.trim().length);
const isLastInstallment =
currentInstallment === installmentCount &&
@@ -201,13 +211,18 @@ const buildColumns = ({
return (
<span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} />
<span className="flex flex-col">
<span className="text-[11px] text-muted-foreground">
<span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[160px] font-semibold truncate">
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name}
</span>
</TooltipTrigger>
@@ -259,12 +274,6 @@ const buildColumns = ({
</Badge>
) : null}
{dueDateLabel ? (
<Badge variant="outline" className="px-2 text-xs">
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
@@ -301,6 +310,21 @@ const buildColumns = ({
</TooltipContent>
</Tooltip>
) : null}
{hasAttachments ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiAttachment2
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Possui anexos</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Possui anexos</TooltipContent>
</Tooltip>
) : null}
</span>
);
},
@@ -366,7 +390,7 @@ const buildColumns = ({
return (
<span className="flex items-center gap-2">
{icon}
<span>{method}</span>
<span>{getPaymentMethodTableLabel(method)}</span>
</span>
);
},

View File

@@ -33,6 +33,7 @@ export type TransactionItem = {
isAnticipated: boolean;
anticipationId: string | null;
seriesId: string | null;
hasAttachments: boolean;
readonly?: boolean;
};

View File

@@ -402,6 +402,7 @@ type TransactionRowWithRelations = Partial<typeof transactions.$inferSelect> & {
financialAccount?: AccountRow | null;
card?: CardRow | null;
category?: CategoryRow | null;
hasAttachments?: boolean;
};
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
@@ -442,6 +443,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
isAnticipated: item.isAnticipated ?? false,
anticipationId: item.anticipationId ?? null,
seriesId: item.seriesId ?? null,
hasAttachments: item.hasAttachments ?? false,
readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.category?.name === "Saldo inicial" ||

View File

@@ -15,6 +15,7 @@ import {
categories,
financialAccounts,
payers,
transactionAttachments,
transactions,
} from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
@@ -43,6 +44,7 @@ const DEFAULT_EXCLUDE_INITIAL_BALANCE = true;
const buildInitialBalanceVisibilityFilter = () =>
or(
isNull(transactions.note),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
@@ -73,6 +75,7 @@ const mapTransactionRows = (
financialAccount: typeof financialAccounts.$inferSelect | null;
card: typeof cards.$inferSelect | null;
category: typeof categories.$inferSelect | null;
hasAttachments: boolean;
}[],
) =>
transactionRows.map((row) => ({
@@ -81,6 +84,7 @@ const mapTransactionRows = (
financialAccount: row.financialAccount,
card: row.card,
category: row.category,
hasAttachments: row.hasAttachments,
}));
async function selectTransactionsWithRelations({
@@ -97,6 +101,10 @@ async function selectTransactionsWithRelations({
financialAccount: financialAccounts,
card: cards,
category: categories,
hasAttachments: sql<boolean>`EXISTS (
SELECT 1 FROM ${transactionAttachments}
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
)`,
})
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))

1
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -1,5 +1,6 @@
"use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import type { VariantProps } from "class-variance-authority";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { buttonVariants } from "@/shared/components/ui/button";
@@ -13,11 +14,13 @@ import { cn } from "@/shared/utils/ui";
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number;
variant?: VariantProps<typeof buttonVariants>["variant"];
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
variant = "ghost",
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false);
@@ -84,10 +87,10 @@ export const AnimatedThemeToggler = ({
onClick={toggleTheme}
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
buttonVariants({ variant, size: "icon-sm" }),
"group relative transition-all duration-200",
variant === "ghost" &&
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
className,
)}
{...props}

View File

@@ -1,20 +1,20 @@
import Image from "next/image";
import { version } from "@/package.json";
import { cn } from "@/shared/utils/ui";
interface LogoProps {
variant?: "full" | "small" | "compact";
className?: string;
showVersion?: boolean;
/** Apenas nos variants "full" e "compact" */
invertTextOnDark?: boolean;
/** Exibe o ícone na cor original, sem filtro preto */
/** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
colorIcon?: boolean;
}
const iconFilterClass = "brightness-0 saturate-0";
export function Logo({
variant = "full",
className,
showVersion = false,
invertTextOnDark = true,
colorIcon = false,
}: LogoProps) {
@@ -26,10 +26,7 @@ export function Logo({
alt="OpenMonetis"
width={32}
height={32}
className={cn(
"object-contain",
!colorIcon && "brightness-0 saturate-0",
)}
className={cn("object-contain", !colorIcon && iconFilterClass)}
priority
/>
<Image
@@ -67,7 +64,7 @@ export function Logo({
alt="OpenMonetis"
width={28}
height={28}
className="object-contain"
className={cn("object-contain", !colorIcon && iconFilterClass)}
priority
/>
<Image
@@ -78,11 +75,6 @@ export function Logo({
className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority
/>
{showVersion && (
<span className="text-[9px] font-medium text-muted-foreground">
{version}
</span>
)}
</div>
);
}

View File

@@ -37,7 +37,7 @@ export default function MonthNavigation() {
};
return (
<Card className="sticky top-16 z-10 flex w-full flex-row p-4">
<Card className="sticky top-16 z-10 flex w-full flex-row p-4 backdrop-blur-xs supports-backdrop-filter:bg-card/80">
<div className="flex items-center gap-1">
<NavigationButton
direction="left"

View File

@@ -1,10 +1,9 @@
import Link from "next/link";
import type { DashboardNotificationsSnapshot } from "@/features/dashboard/notifications-queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo";
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
import { NavMenu } from "./nav-menu";
import { NavbarShell } from "./navbar-shell";
import { NavbarUser } from "./navbar-user";
type AppNavbarProps = {
@@ -19,9 +18,6 @@ type AppNavbarProps = {
notificationsSnapshot: DashboardNotificationsSnapshot;
};
const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export function AppNavbar({
user,
pagadorAvatarUrl,
@@ -29,27 +25,20 @@ export function AppNavbar({
notificationsSnapshot,
}: AppNavbarProps) {
return (
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary">
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
<Link href="/dashboard" className="shrink-0 mr-1">
<Logo variant="compact" invertTextOnDark={false} />
</Link>
<NavMenu />
<div className="ml-auto flex items-center gap-2">
<NotificationBell
notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/>
<RefreshPageButton className={navbarActionClassName} />
<AnimatedThemeToggler className={navbarActionClassName} />
</div>
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
<NavbarShell logoHref="/dashboard" fixed>
<NavMenu />
<div className="ml-auto flex items-center gap-2">
<NotificationBell
notifications={notificationsSnapshot.notifications}
unreadCount={notificationsSnapshot.unreadCount}
visibleCount={notificationsSnapshot.visibleCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/>
<RefreshPageButton variant="navbar" />
<AnimatedThemeToggler variant="navbar" />
</div>
</header>
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
</NavbarShell>
);
}

View File

@@ -20,13 +20,18 @@ import {
SheetTitle,
SheetTrigger,
} from "@/shared/components/ui/sheet";
import { cn } from "@/shared/utils/ui";
import { MobileLink, MobileSectionLabel } from "./mobile-link";
import { NavDropdown } from "./nav-dropdown";
import { NAV_SECTIONS } from "./nav-items";
import { NavPill } from "./nav-pill";
import { triggerActiveClass, triggerClass } from "./nav-styles";
import { MobileTools, NavToolsDropdown } from "./nav-tools";
const triggerClass =
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! lowercase! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
const triggerActiveClass = "bg-black/15! text-black!";
export function NavMenu() {
const pathname = usePathname();
const [sheetOpen, setSheetOpen] = useState(false);
@@ -55,7 +60,10 @@ export function NavMenu() {
return (
<NavigationMenuItem key={section.label}>
<NavigationMenuTrigger
className={`${triggerClass} ${isSectionActive ? triggerActiveClass : ""}`}
className={cn(
triggerClass,
isSectionActive && triggerActiveClass,
)}
>
{section.label}
</NavigationMenuTrigger>
@@ -82,9 +90,9 @@ export function NavMenu() {
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="-order-1 border border-black/10 text-black/75 shadow-none md:hidden hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
variant="navbar"
size="icon-sm"
className="-order-1 md:hidden"
>
<RiMenuLine className="size-5" />
<span className="sr-only">Abrir menu</span>

View File

@@ -1,9 +1,9 @@
"use client";
import { usePathname } from "next/navigation";
import { buttonVariants } from "@/shared/components/ui/button";
import { cn } from "@/shared/utils/ui";
import { NavLink } from "./nav-link";
import { linkActive, linkBase, linkIdle } from "./nav-styles";
type NavPillProps = {
href: string;
@@ -23,7 +23,11 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
<NavLink
href={href}
preservePeriod={preservePeriod}
className={cn(linkBase, isActive ? linkActive : linkIdle)}
className={cn(
buttonVariants({ variant: "navbar", size: "sm" }),
"lowercase",
isActive && "bg-black/15 text-black",
)}
>
{children}
</NavLink>

View File

@@ -1,29 +0,0 @@
export const linkBase =
"inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all lowercase";
export const linkIdle = "text-black/75 hover:bg-black/10 hover:text-black";
export const linkActive = "bg-black/15 text-black";
export const triggerActiveClass = ["bg-black/15!", "text-black!"].join(" ");
export const triggerClass = [
"h-8!",
"rounded-md!",
"px-2!",
"py-0!",
"text-sm!",
"font-medium!",
"bg-transparent!",
"text-black/75!",
"hover:text-black!",
"hover:bg-black/10!",
"focus:text-black!",
"focus:bg-black/10!",
"focus-visible:ring-black/20!",
"data-[state=open]:text-black!",
"data-[state=open]:bg-black/10!",
"shadow-none!",
"[&_svg]:text-current!",
"lowercase!",
].join(" ");

View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import { Logo } from "@/shared/components/logo";
type NavbarShellProps = {
logoHref?: string;
fixed?: boolean;
children: React.ReactNode;
};
export function NavbarShell({
logoHref,
fixed = false,
children,
}: NavbarShellProps) {
const positionClass = fixed ? "fixed top-0 left-0 right-0" : "sticky top-0";
return (
<header
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary`}
>
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
{logoHref ? (
<Link href={logoHref} className="shrink-0">
<Logo variant="compact" invertTextOnDark={false} />
</Link>
) : (
<Logo variant="compact" invertTextOnDark={false} />
)}
{children}
</div>
</header>
);
}

View File

@@ -63,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full shadow-none transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
aria-label="Menu do usuário"
>
<Image

View File

@@ -1,347 +1,81 @@
"use client";
import {
RiAlertFill,
RiArrowRightLine,
RiAtLine,
RiBankCardLine,
RiBarChart2Line,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiFileListLine,
RiNotification2Line,
RiTimeLine,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import type {
BudgetNotification,
DashboardNotification,
} from "@/features/dashboard/notifications-queries";
import { Badge } from "@/shared/components/ui/badge";
import { buttonVariants } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyMedia,
EmptyTitle,
} from "@/shared/components/ui/empty";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date";
import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
import { NotificationBellContent } from "./notification-bell/notification-bell-content";
import { NotificationBellEmptyState } from "./notification-bell/notification-bell-empty-state";
import { NotificationBellHeader } from "./notification-bell/notification-bell-header";
import { NotificationBellTrigger } from "./notification-bell/notification-bell-trigger";
import type { NotificationBellProps } from "./notification-bell/types";
import { useNotificationBell } from "./notification-bell/use-notification-bell";
type NotificationBellProps = {
notifications: DashboardNotification[];
totalCount: number;
budgetNotifications: BudgetNotification[];
preLancamentosCount?: number;
};
function formatDate(dateString: string): string {
return (
formatDateOnly(dateString, {
day: "2-digit",
month: "short",
}) ?? dateString
);
}
function SectionLabel({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div className="flex items-center gap-1.5 px-3 pb-1 pt-3">
<span className="text-muted-foreground">{icon}</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</span>
</div>
);
}
type NotificationItemProps = {
href: string;
icon: React.ReactNode;
isOverdue: boolean;
title: string;
detail: string;
onClose: () => void;
};
function NotificationItem({
href,
icon,
isOverdue,
title,
detail,
onClose,
}: NotificationItemProps) {
return (
<Link
href={href}
onClick={onClose}
className={cn(
"group mx-1 mb-0.5 flex items-start gap-2.5 rounded-md px-2 py-2 transition-colors hover:bg-accent/60",
isOverdue && "bg-destructive/5 hover:bg-destructive/10",
)}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="flex flex-1 flex-col gap-0.5 min-w-0">
<span
className={cn(
"text-[12px] font-medium leading-snug",
isOverdue ? "text-destructive" : "text-foreground",
)}
>
{title}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{detail}
</span>
</span>
<RiArrowRightLine className="mt-0.5 size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
</Link>
);
}
export function NotificationBell({
notifications,
totalCount,
budgetNotifications,
preLancamentosCount = 0,
}: NotificationBellProps) {
const [open, setOpen] = useState(false);
const effectiveTotalCount =
totalCount + preLancamentosCount + budgetNotifications.length;
const displayCount =
effectiveTotalCount > 99 ? "99+" : effectiveTotalCount.toString();
const hasNotifications = effectiveTotalCount > 0;
const invoiceNotifications = notifications.filter(
(n) => n.type === "invoice",
);
const boletoNotifications = notifications.filter((n) => n.type === "boleto");
export function NotificationBell(props: NotificationBellProps) {
const {
open,
setOpen,
viewMode,
setViewMode,
displayCount,
hasUnreadNotifications,
hasAnySourceItems,
headerCountLabel,
hasDashboardNotificationItems,
hasArchivedItems,
archivedDashboardCount,
hasVisibleItems,
displayedPreLancamentosCount,
displayedBudgetNotifications,
invoiceNotifications,
boletoNotifications,
handleInboxNavigate,
handleNotificationNavigate,
handleToggleRead,
handleToggleArchive,
showArchived,
} = useNotificationBell(props);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Notificações"
aria-expanded={open}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative shadow-none transition-all duration-200",
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
hasNotifications ? "text-black" : "text-black/75",
)}
>
<RiNotification2Line
className={cn(
"size-4 transition-transform duration-200",
open ? "scale-90" : "scale-100",
)}
/>
{hasNotifications && (
<>
<span
aria-hidden
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[12px] font-semibold text-destructive-foreground"
>
{displayCount}
</span>
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/40" />
</>
)}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Notificações
</TooltipContent>
</Tooltip>
<NotificationBellTrigger
open={open}
hasAnySourceItems={hasAnySourceItems}
hasUnreadNotifications={hasUnreadNotifications}
displayCount={displayCount}
/>
<DropdownMenuContent
align="end"
sideOffset={12}
className="w-80 overflow-hidden rounded-lg border border-border/60 bg-popover p-0 shadow-none"
className="w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg p-0 shadow-lg"
>
{/* Header */}
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-[12px] font-semibold">
<span>Notificações</span>
{hasNotifications && (
<Badge variant="outline" className="text-[10px] font-semibold">
{effectiveTotalCount}{" "}
{effectiveTotalCount === 1 ? "item" : "itens"}
</Badge>
)}
</DropdownMenuLabel>
<NotificationBellHeader
hasAnySourceItems={hasAnySourceItems}
headerCountLabel={headerCountLabel}
hasDashboardNotificationItems={hasDashboardNotificationItems}
viewMode={viewMode}
hasArchivedItems={hasArchivedItems}
archivedDashboardCount={archivedDashboardCount}
onViewModeChange={setViewMode}
/>
{!hasNotifications ? (
<div className="px-4 py-8">
<Empty>
<EmptyMedia>
<RiCheckboxCircleFill color="green" />
</EmptyMedia>
<EmptyTitle>Nenhuma notificação</EmptyTitle>
<EmptyDescription>
Você está em dia com seus pagamentos!
</EmptyDescription>
</Empty>
</div>
{hasVisibleItems ? (
<NotificationBellContent
displayedPreLancamentosCount={displayedPreLancamentosCount}
displayedBudgetNotifications={displayedBudgetNotifications}
invoiceNotifications={invoiceNotifications}
boletoNotifications={boletoNotifications}
onInboxNavigate={handleInboxNavigate}
onNotificationNavigate={handleNotificationNavigate}
onToggleRead={handleToggleRead}
onToggleArchive={handleToggleArchive}
/>
) : (
<div className="max-h-[460px] overflow-y-auto pb-2">
{/* Pré-lançamentos */}
{preLancamentosCount > 0 && (
<div>
<SectionLabel
icon={<RiAtLine className="size-3" />}
title="Pré-lançamentos"
/>
<NotificationItem
href="/inbox"
isOverdue={false}
icon={<RiAtLine className="size-5 text-primary" />}
title={
preLancamentosCount === 1
? "1 pré-lançamento pendente"
: `${preLancamentosCount} pré-lançamentos pendentes`
}
detail="Aguardando revisão"
onClose={() => setOpen(false)}
/>
</div>
)}
{/* Orçamentos */}
{budgetNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBarChart2Line className="size-3" />}
title="Orçamentos"
/>
{budgetNotifications.map((n) => (
<NotificationItem
key={n.id}
href="/budgets"
isOverdue={n.status === "exceeded"}
icon={
n.status === "exceeded" ? (
<RiAlertFill className="size-5 text-destructive" />
) : (
<RiErrorWarningLine className="size-5 text-amber-500" />
)
}
title={n.categoryName}
detail={
n.status === "exceeded"
? `Excedido — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)} (${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })})`
: `${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })} utilizado — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)}`
}
onClose={() => setOpen(false)}
/>
))}
</div>
)}
{/* Cartão de Crédito */}
{invoiceNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBankCardLine className="size-3" />}
title="Cartão de Crédito"
/>
{invoiceNotifications.map((n) => {
const logo = resolveLogoSrc(n.cardLogo);
return (
<NotificationItem
key={n.id}
href="/cards"
isOverdue={n.status === "overdue"}
icon={
logo ? (
<Image
src={logo}
alt=""
width={20}
height={20}
className="size-5 rounded-full object-contain"
/>
) : n.status === "overdue" ? (
<RiAlertFill className="size-5 text-destructive" />
) : (
<RiTimeLine className="size-5 text-amber-500" />
)
}
title={n.name}
detail={
n.status === "overdue"
? `Venceu em ${formatDate(n.dueDate)}${n.showAmount && n.amount > 0 ? `${formatCurrency(n.amount)}` : ""}`
: `Vence em ${formatDate(n.dueDate)}${n.showAmount && n.amount > 0 ? `${formatCurrency(n.amount)}` : ""}`
}
onClose={() => setOpen(false)}
/>
);
})}
</div>
)}
{/* Boletos */}
{boletoNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiFileListLine className="size-3" />}
title="Boletos"
/>
{boletoNotifications.map((n) => (
<NotificationItem
key={n.id}
href="/transactions"
isOverdue={n.status === "overdue"}
icon={
<RiAlertFill
className={cn(
"size-5",
n.status === "overdue"
? "text-destructive"
: "text-amber-500",
)}
/>
}
title={n.name}
detail={
n.status === "overdue"
? `Venceu em ${formatDate(n.dueDate)}${n.amount > 0 ? `${formatCurrency(n.amount)}` : ""}`
: `Vence em ${formatDate(n.dueDate)}${n.amount > 0 ? `${formatCurrency(n.amount)}` : ""}`
}
onClose={() => setOpen(false)}
/>
))}
</div>
)}
</div>
<NotificationBellEmptyState
showArchived={showArchived}
hasArchivedItems={hasArchivedItems}
/>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,457 @@
"use client";
import {
RiAlertFill,
RiArchiveLine,
RiArrowGoBackLine,
RiAtLine,
RiBankCardLine,
RiBarChart2Line,
RiCheckLine,
RiErrorWarningLine,
RiFileListLine,
RiInboxUnarchiveLine,
RiTimeLine,
} from "@remixicon/react";
import Image from "next/image";
import StatusDot from "@/shared/components/status-dot";
import { buttonVariants } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date";
import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
import type {
ResolvedBudgetNotification,
ResolvedDashboardNotification,
StatefulNotification,
} from "./types";
type NotificationBellContentProps = {
displayedPreLancamentosCount: number;
displayedBudgetNotifications: ResolvedBudgetNotification[];
invoiceNotifications: ResolvedDashboardNotification[];
boletoNotifications: ResolvedDashboardNotification[];
onInboxNavigate: () => void;
onNotificationNavigate: (notification: StatefulNotification) => Promise<void>;
onToggleRead: (notification: StatefulNotification) => Promise<void>;
onToggleArchive: (notification: StatefulNotification) => Promise<void>;
};
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function formatDate(dateString: string): string {
return (
formatDateOnly(dateString, { day: "2-digit", month: "short" }) ?? dateString
);
}
function getReadAction(notification: StatefulNotification) {
return {
label: notification.isRead ? "Marcar como não lida" : "Marcar como lida",
icon: notification.isRead ? (
<RiArrowGoBackLine className="size-4" />
) : (
<RiCheckLine className="size-4" />
),
};
}
function getArchiveAction(notification: StatefulNotification) {
return {
label: notification.isArchived
? "Desarquivar notificação"
: "Arquivar notificação",
icon: notification.isArchived ? (
<RiInboxUnarchiveLine className="size-4" />
) : (
<RiArchiveLine className="size-4" />
),
};
}
// ---------------------------------------------------------------------------
// SectionLabel
// ---------------------------------------------------------------------------
function SectionLabel({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div className="flex items-center gap-1.5 p-2 first:pt-1">
<span className="text-muted-foreground">{icon}</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Action button
// ---------------------------------------------------------------------------
function NotificationActionButton({
label,
icon,
onClick,
disabled = false,
}: {
label: string;
icon: React.ReactNode;
onClick?: () => void | Promise<void>;
disabled?: boolean;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
void onClick?.();
}}
disabled={disabled}
aria-label={label}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"size-7 text-muted-foreground opacity-0 transition-all group-hover/item:opacity-100 hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50",
)}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// NotificationItem
// ---------------------------------------------------------------------------
type NotificationItemProps = {
icon: React.ReactNode;
isOverdue: boolean;
isRead?: boolean;
isArchived?: boolean;
isBusy?: boolean;
showUnreadIndicator?: boolean;
title: string;
detail: string;
onNavigate: () => void | Promise<void>;
onToggleRead?: () => void | Promise<void>;
onToggleArchive?: () => void | Promise<void>;
notification?: StatefulNotification;
};
function NotificationItem({
icon,
isOverdue,
isRead = false,
isArchived = false,
isBusy = false,
showUnreadIndicator = false,
title,
detail,
onNavigate,
onToggleRead,
onToggleArchive,
notification,
}: NotificationItemProps) {
const readAction = notification ? getReadAction(notification) : null;
const archiveAction = notification ? getArchiveAction(notification) : null;
return (
<div
className={cn(
"group/item mx-1 mb-0.5 flex items-center gap-2.5 rounded-md px-2.5 py-2 transition-colors",
isArchived
? "opacity-60"
: isOverdue && !isRead
? "bg-destructive/5"
: "hover:bg-accent/50",
)}
>
<button
type="button"
onClick={() => {
void onNavigate();
}}
disabled={isBusy}
className="flex min-w-0 flex-1 items-start gap-2.5 text-left disabled:cursor-wait disabled:opacity-80"
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
"min-w-0 truncate text-xs font-medium leading-snug",
isOverdue && !isRead
? "text-destructive"
: isRead
? "text-muted-foreground"
: "text-foreground",
)}
>
{title}
</span>
{showUnreadIndicator && !isRead && (
<StatusDot color="bg-destructive/80" className="size-1.5" />
)}
</span>
<span
className={cn(
"text-xs leading-snug",
isRead ? "text-muted-foreground/70" : "text-muted-foreground",
)}
>
{detail}
</span>
</span>
</button>
{(readAction || archiveAction) && (
<div className="flex w-16 shrink-0 items-center justify-end gap-0.5">
{readAction && onToggleRead && (
<NotificationActionButton
label={readAction.label}
icon={readAction.icon}
onClick={onToggleRead}
disabled={isBusy}
/>
)}
{archiveAction && onToggleArchive && (
<NotificationActionButton
label={archiveAction.label}
icon={archiveAction.icon}
onClick={onToggleArchive}
disabled={isBusy}
/>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// NotificationSection — generic wrapper to eliminate per-type repetition
// ---------------------------------------------------------------------------
type NotificationSectionProps<
T extends StatefulNotification & { isBusy: boolean },
> = {
icon: React.ReactNode;
title: string;
items: T[];
renderIcon: (item: T) => React.ReactNode;
renderTitle: (item: T) => string;
renderDetail: (item: T) => string;
isOverdue: (item: T) => boolean;
showUnreadIndicator?: boolean;
onNavigate: (item: T) => void | Promise<void>;
onToggleRead: (item: T) => void | Promise<void>;
onToggleArchive: (item: T) => void | Promise<void>;
};
function NotificationSection<
T extends StatefulNotification & { isBusy: boolean },
>({
icon,
title,
items,
renderIcon,
renderTitle,
renderDetail,
isOverdue,
showUnreadIndicator = false,
onNavigate,
onToggleRead,
onToggleArchive,
}: NotificationSectionProps<T>) {
if (items.length === 0) return null;
return (
<div>
<SectionLabel icon={icon} title={title} />
{items.map((item) => (
<NotificationItem
key={item.notificationKey}
isOverdue={isOverdue(item)}
isRead={item.isRead}
isArchived={item.isArchived}
isBusy={item.isBusy}
showUnreadIndicator={showUnreadIndicator}
icon={renderIcon(item)}
title={renderTitle(item)}
detail={renderDetail(item)}
onNavigate={() => onNavigate(item)}
onToggleRead={() => onToggleRead(item)}
onToggleArchive={() => onToggleArchive(item)}
notification={item}
/>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Icon helpers (consistent between invoice / boleto)
// ---------------------------------------------------------------------------
function DueDateIcon({ isOverdue }: { isOverdue: boolean }) {
return isOverdue ? (
<RiAlertFill className="size-5 text-destructive" />
) : (
<RiTimeLine className="size-5 text-amber-500" />
);
}
function InvoiceIcon({
cardLogo,
isOverdue,
}: {
cardLogo?: string | null;
isOverdue: boolean;
}) {
const logo = resolveLogoSrc(cardLogo);
if (logo) {
return (
<Image
src={logo}
alt=""
width={20}
height={20}
className="size-5 rounded-full object-contain"
/>
);
}
return <DueDateIcon isOverdue={isOverdue} />;
}
function formatDueDateDetail(
status: string,
dueDate: string,
amount: number,
showAmount: boolean,
) {
const verb = status === "overdue" ? "Venceu em" : "Vence em";
const amountStr =
showAmount && amount > 0 ? `${formatCurrency(amount)}` : "";
return `${verb} ${formatDate(dueDate)}${amountStr}`;
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function NotificationBellContent({
displayedPreLancamentosCount,
displayedBudgetNotifications,
invoiceNotifications,
boletoNotifications,
onInboxNavigate,
onNotificationNavigate,
onToggleRead,
onToggleArchive,
}: NotificationBellContentProps) {
return (
<div className="max-h-[460px] overflow-y-auto p-2">
{displayedPreLancamentosCount > 0 && (
<div>
<SectionLabel
icon={<RiAtLine className="size-3" />}
title="Pré-lançamentos"
/>
<NotificationItem
icon={<RiAtLine className="size-5 text-primary" />}
isOverdue={false}
title={
displayedPreLancamentosCount === 1
? "1 pré-lançamento pendente"
: `${displayedPreLancamentosCount} pré-lançamentos pendentes`
}
detail="Aguardando revisão"
onNavigate={onInboxNavigate}
/>
</div>
)}
<NotificationSection
icon={<RiBarChart2Line className="size-3" />}
title="Orçamentos"
items={displayedBudgetNotifications}
isOverdue={(n) => n.status === "exceeded"}
showUnreadIndicator
renderIcon={(n) =>
n.status === "exceeded" ? (
<RiAlertFill className="size-5 text-destructive" />
) : (
<RiErrorWarningLine className="size-5 text-amber-500" />
)
}
renderTitle={(n) => n.categoryName}
renderDetail={(n) =>
n.status === "exceeded"
? `Excedido — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)} (${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })})`
: `${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })} utilizado — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)}`
}
onNavigate={(n) => onNotificationNavigate(n)}
onToggleRead={(n) => onToggleRead(n)}
onToggleArchive={(n) => onToggleArchive(n)}
/>
<NotificationSection
icon={<RiBankCardLine className="size-3" />}
title="Cartão de Crédito"
items={invoiceNotifications}
isOverdue={(n) => n.status === "overdue"}
showUnreadIndicator
renderIcon={(n) => (
<InvoiceIcon
cardLogo={n.cardLogo}
isOverdue={n.status === "overdue"}
/>
)}
renderTitle={(n) => n.name}
renderDetail={(n) =>
formatDueDateDetail(n.status, n.dueDate, n.amount, n.showAmount)
}
onNavigate={(n) => onNotificationNavigate(n)}
onToggleRead={(n) => onToggleRead(n)}
onToggleArchive={(n) => onToggleArchive(n)}
/>
<NotificationSection
icon={<RiFileListLine className="size-3" />}
title="Boletos"
items={boletoNotifications}
isOverdue={(n) => n.status === "overdue"}
showUnreadIndicator
renderIcon={(n) => <DueDateIcon isOverdue={n.status === "overdue"} />}
renderTitle={(n) => n.name}
renderDetail={(n) =>
formatDueDateDetail(n.status, n.dueDate, n.amount, true)
}
onNavigate={(n) => onNotificationNavigate(n)}
onToggleRead={(n) => onToggleRead(n)}
onToggleArchive={(n) => onToggleArchive(n)}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { RiArchiveLine, RiCheckboxCircleFill } from "@remixicon/react";
import {
Empty,
EmptyDescription,
EmptyMedia,
EmptyTitle,
} from "@/shared/components/ui/empty";
type NotificationBellEmptyStateProps = {
showArchived: boolean;
hasArchivedItems: boolean;
};
export function NotificationBellEmptyState({
showArchived,
hasArchivedItems,
}: NotificationBellEmptyStateProps) {
return (
<div className="px-4 py-8">
<Empty>
<EmptyMedia>
{showArchived ? (
<RiArchiveLine className="text-muted-foreground" />
) : (
<RiCheckboxCircleFill color="green" />
)}
</EmptyMedia>
<EmptyTitle>
{showArchived
? "Nenhuma notificação arquivada"
: hasArchivedItems
? "Nenhuma notificação ativa"
: "Nenhuma notificação"}
</EmptyTitle>
<EmptyDescription>
{showArchived
? "Você ainda não arquivou nenhuma notificação."
: hasArchivedItems
? "As demais notificações estão arquivadas. Ative o filtro para revê-las."
: "Você está em dia com seus pagamentos!"}
</EmptyDescription>
</Empty>
</div>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { Badge } from "@/shared/components/ui/badge";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
import type { NotificationViewMode } from "./types";
type NotificationBellHeaderProps = {
hasAnySourceItems: boolean;
headerCountLabel: string;
hasDashboardNotificationItems: boolean;
viewMode: NotificationViewMode;
hasArchivedItems: boolean;
archivedDashboardCount: number;
onViewModeChange: (viewMode: NotificationViewMode) => void;
};
export function NotificationBellHeader({
hasAnySourceItems,
headerCountLabel,
hasDashboardNotificationItems,
viewMode,
hasArchivedItems,
archivedDashboardCount,
onViewModeChange,
}: NotificationBellHeaderProps) {
return (
<div className="border-b px-3 py-2.5">
<div className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>Notificações</span>
{hasAnySourceItems ? (
<Badge variant="outline" className="text-xs font-medium">
{headerCountLabel}
</Badge>
) : null}
</div>
{hasDashboardNotificationItems ? (
<div className="pt-2.5">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value) => {
if (!value) return;
if (value === "archived" && !hasArchivedItems) return;
onViewModeChange(value as NotificationViewMode);
}}
variant="outline"
size="sm"
className="w-full rounded-md bg-muted/30 p-0.5"
aria-label="Filtro da lista de notificações"
>
<ToggleGroupItem
value="active"
className="flex-1 text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
aria-label="Mostrar notificações ativas"
>
Ativas
</ToggleGroupItem>
<ToggleGroupItem
value="archived"
className="flex-1 text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
aria-label="Mostrar notificações arquivadas"
disabled={!hasArchivedItems && viewMode !== "archived"}
>
Arquivadas
{hasArchivedItems ? ` (${archivedDashboardCount})` : ""}
</ToggleGroupItem>
</ToggleGroup>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { RiNotification2Line } from "@remixicon/react";
import { buttonVariants } from "@/shared/components/ui/button";
import { DropdownMenuTrigger } from "@/shared/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
type NotificationBellTriggerProps = {
open: boolean;
hasAnySourceItems: boolean;
hasUnreadNotifications: boolean;
displayCount: string;
};
export function NotificationBellTrigger({
open,
hasAnySourceItems,
hasUnreadNotifications,
displayCount,
}: NotificationBellTriggerProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Notificações"
aria-expanded={open}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative shadow-none transition-all duration-200",
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
hasAnySourceItems ? "text-black" : "text-black/75",
)}
>
<RiNotification2Line
className={cn(
"size-4 transition-transform duration-200",
open ? "scale-90" : "scale-100",
)}
/>
{hasUnreadNotifications ? (
<>
<span
aria-hidden
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs text-white"
>
{displayCount}
</span>
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 [animation-iteration-count:3]" />
</>
) : null}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Notificações
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,38 @@
import type {
BudgetNotification,
DashboardNotification,
} from "@/shared/lib/types/notifications";
export type StatefulNotification = {
notificationKey: string;
fingerprint: string;
href: string;
isRead: boolean;
isArchived: boolean;
readAt: Date | null;
archivedAt: Date | null;
};
export type NotificationActionState = {
isRead: boolean;
isArchived: boolean;
isBusy: boolean;
};
export type NotificationViewMode = "active" | "archived";
export type ResolvedDashboardNotification = DashboardNotification & {
isBusy: boolean;
};
export type ResolvedBudgetNotification = BudgetNotification & {
isBusy: boolean;
};
export type NotificationBellProps = {
notifications: DashboardNotification[];
unreadCount: number;
visibleCount: number;
budgetNotifications: BudgetNotification[];
preLancamentosCount?: number;
};

View File

@@ -0,0 +1,319 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
archiveDashboardNotificationAction,
markDashboardNotificationAsReadAction,
markDashboardNotificationAsUnreadAction,
unarchiveDashboardNotificationAction,
} from "@/features/dashboard/notifications-actions";
import type {
NotificationActionState,
NotificationBellProps,
NotificationViewMode,
ResolvedBudgetNotification,
ResolvedDashboardNotification,
StatefulNotification,
} from "./types";
type NotificationAction = "read" | "unread" | "archive" | "unarchive";
type UseNotificationBellReturn = {
open: boolean;
setOpen: (open: boolean) => void;
viewMode: NotificationViewMode;
setViewMode: (viewMode: NotificationViewMode) => void;
displayCount: string;
hasUnreadNotifications: boolean;
hasAnySourceItems: boolean;
headerCountLabel: string;
hasDashboardNotificationItems: boolean;
hasArchivedItems: boolean;
archivedDashboardCount: number;
hasVisibleItems: boolean;
displayedPreLancamentosCount: number;
displayedBudgetNotifications: ResolvedBudgetNotification[];
invoiceNotifications: ResolvedDashboardNotification[];
boletoNotifications: ResolvedDashboardNotification[];
handleInboxNavigate: () => void;
handleNotificationNavigate: (
notification: StatefulNotification,
) => Promise<void>;
handleToggleRead: (notification: StatefulNotification) => Promise<void>;
handleToggleArchive: (notification: StatefulNotification) => Promise<void>;
showArchived: boolean;
};
const optimisticStateByAction: Record<
NotificationAction,
(notification: StatefulNotification) => NotificationActionState
> = {
archive: () => ({ isRead: true, isArchived: true, isBusy: true }),
unarchive: () => ({ isRead: true, isArchived: false, isBusy: true }),
read: (n) => ({
isRead: true,
isArchived: n.isArchived,
isBusy: true,
}),
unread: (n) => ({
isRead: false,
isArchived: n.isArchived,
isBusy: true,
}),
};
const serverActionByType: Record<
NotificationAction,
(input: {
notificationKey: string;
fingerprint: string;
}) => Promise<{ success: boolean; message?: string; error?: string }>
> = {
archive: archiveDashboardNotificationAction,
unarchive: unarchiveDashboardNotificationAction,
read: markDashboardNotificationAsReadAction,
unread: markDashboardNotificationAsUnreadAction,
};
export function useNotificationBell({
notifications,
unreadCount: initialUnreadCount,
visibleCount: initialVisibleCount,
budgetNotifications,
preLancamentosCount = 0,
}: NotificationBellProps): UseNotificationBellReturn {
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<NotificationViewMode>("active");
const [notificationActions, setNotificationActions] = useState<
Record<string, NotificationActionState>
>({});
const router = useRouter();
const showArchived = viewMode === "archived";
// Limpar estado otimista quando o server retorna dados novos (via router.refresh)
const prevNotificationsRef = useRef(notifications);
const prevBudgetRef = useRef(budgetNotifications);
useEffect(() => {
if (
prevNotificationsRef.current !== notifications ||
prevBudgetRef.current !== budgetNotifications
) {
prevNotificationsRef.current = notifications;
prevBudgetRef.current = budgetNotifications;
setNotificationActions({});
}
}, [notifications, budgetNotifications]);
const resolveNotificationState = <T extends StatefulNotification>(
notification: T,
): T & { isBusy: boolean } => {
const actionState = notificationActions[notification.notificationKey];
if (!actionState) {
return { ...notification, isBusy: false };
}
return {
...notification,
isRead: actionState.isRead,
isArchived: actionState.isArchived,
isBusy: actionState.isBusy,
};
};
const allResolvedNotifications = notifications.map((notification) =>
resolveNotificationState(notification),
);
const allResolvedBudgetNotifications = budgetNotifications.map(
(notification) => resolveNotificationState(notification),
);
const activeNotifications = allResolvedNotifications.filter(
(notification) => !notification.isArchived,
);
const activeBudgetNotifications = allResolvedBudgetNotifications.filter(
(notification) => !notification.isArchived,
);
const archivedNotifications = allResolvedNotifications.filter(
(notification) => notification.isArchived,
);
const archivedBudgetNotifications = allResolvedBudgetNotifications.filter(
(notification) => notification.isArchived,
);
const displayedNotifications = showArchived
? archivedNotifications
: activeNotifications;
const displayedBudgetNotifications = showArchived
? archivedBudgetNotifications
: activeBudgetNotifications;
const invoiceNotifications = displayedNotifications.filter(
(notification) => notification.type === "invoice",
);
const boletoNotifications = displayedNotifications.filter(
(notification) => notification.type === "boleto",
);
const unreadDashboardCount = [
...activeNotifications,
...activeBudgetNotifications,
].filter((notification) => !notification.isRead).length;
const activeDashboardCountFromItems =
activeNotifications.length + activeBudgetNotifications.length;
const displayedDashboardCountFromItems =
displayedNotifications.length + displayedBudgetNotifications.length;
const archivedDashboardCount =
allResolvedNotifications.length +
allResolvedBudgetNotifications.length -
activeDashboardCountFromItems;
const dashboardNotificationCount =
allResolvedNotifications.length + allResolvedBudgetNotifications.length;
const hasOptimisticState = Object.keys(notificationActions).length > 0;
const unreadDashboardCountValue = hasOptimisticState
? unreadDashboardCount
: initialUnreadCount;
const activeDashboardCount = hasOptimisticState
? activeDashboardCountFromItems
: initialVisibleCount;
const displayedDashboardCount = showArchived
? displayedDashboardCountFromItems
: activeDashboardCount;
const displayedPreLancamentosCount = showArchived ? 0 : preLancamentosCount;
const effectiveUnreadCount = unreadDashboardCountValue + preLancamentosCount;
const displayCount =
effectiveUnreadCount > 99 ? "99+" : effectiveUnreadCount.toString();
const hasUnreadNotifications = effectiveUnreadCount > 0;
const hasVisibleItems =
displayedDashboardCount + displayedPreLancamentosCount > 0;
const hasArchivedItems = archivedDashboardCount > 0;
const hasDashboardNotificationItems = dashboardNotificationCount > 0;
const hasAnySourceItems =
allResolvedNotifications.length +
allResolvedBudgetNotifications.length +
preLancamentosCount >
0;
const headerCountLabel = `${effectiveUnreadCount} ${effectiveUnreadCount === 1 ? "pendente" : "pendentes"}`;
useEffect(() => {
if (showArchived && !hasArchivedItems) {
setViewMode("active");
}
}, [hasArchivedItems, showArchived]);
const persistNotificationState = async (
notification: StatefulNotification,
action: NotificationAction,
options?: { showToast?: boolean; refreshAfter?: boolean },
): Promise<boolean> => {
const showToast = options?.showToast ?? true;
const refreshAfter = options?.refreshAfter ?? true;
const previousState: NotificationActionState = {
isRead: notification.isRead,
isArchived: notification.isArchived,
isBusy: false,
};
const optimisticState = optimisticStateByAction[action](notification);
setNotificationActions((current) => ({
...current,
[notification.notificationKey]: optimisticState,
}));
const result = await serverActionByType[action]({
notificationKey: notification.notificationKey,
fingerprint: notification.fingerprint,
});
if (!result.success) {
setNotificationActions((current) => ({
...current,
[notification.notificationKey]: previousState,
}));
if (showToast) {
toast.error(result.error);
}
return false;
}
setNotificationActions((current) => ({
...current,
[notification.notificationKey]: {
isRead: optimisticState.isRead,
isArchived: optimisticState.isArchived,
isBusy: false,
},
}));
if (showToast) {
toast.success(result.message);
}
if (refreshAfter) {
router.refresh();
}
return true;
};
const handleInboxNavigate = () => {
setOpen(false);
router.push("/inbox");
};
const handleNotificationNavigate = async (
notification: StatefulNotification,
) => {
setOpen(false);
if (!notification.isRead) {
await persistNotificationState(notification, "read", {
showToast: false,
refreshAfter: false,
});
}
router.push(notification.href);
};
const handleToggleRead = async (notification: StatefulNotification) => {
await persistNotificationState(
notification,
notification.isRead ? "unread" : "read",
);
};
const handleToggleArchive = async (notification: StatefulNotification) => {
await persistNotificationState(
notification,
notification.isArchived ? "unarchive" : "archive",
);
};
return {
open,
setOpen,
viewMode,
setViewMode,
displayCount,
hasUnreadNotifications,
hasAnySourceItems,
headerCountLabel,
hasDashboardNotificationItems,
hasArchivedItems,
archivedDashboardCount,
hasVisibleItems,
displayedPreLancamentosCount,
displayedBudgetNotifications,
invoiceNotifications,
boletoNotifications,
handleInboxNavigate,
handleNotificationNavigate,
handleToggleRead,
handleToggleArchive,
showArchived,
};
}

View File

@@ -78,6 +78,6 @@ function LogoContent() {
const isCollapsed = state === "collapsed";
return (
<Logo variant={isCollapsed ? "small" : "full"} showVersion={!isCollapsed} />
<Logo variant={isCollapsed ? "small" : "full"} />
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { RiRefreshLine } from "@remixicon/react";
import type { VariantProps } from "class-variance-authority";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { buttonVariants } from "@/shared/components/ui/button";
@@ -11,10 +12,12 @@ import {
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button">;
type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button"> &
Pick<VariantProps<typeof buttonVariants>, "variant">;
export function RefreshPageButton({
className,
variant = "ghost",
...props
}: RefreshPageButtonProps) {
const router = useRouter();
@@ -36,10 +39,10 @@ export function RefreshPageButton({
aria-label="Atualizar página"
title="Atualizar página"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"size-8 text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"disabled:pointer-events-none disabled:opacity-50",
buttonVariants({ variant, size: "icon-sm" }),
"transition-all duration-200",
variant === "ghost" &&
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
className,
)}
{...props}

View File

@@ -19,6 +19,8 @@ const buttonVariants = cva(
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
navbar:
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -56,7 +56,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl",
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl",
className,
)}
{...props}

View File

@@ -31,6 +31,7 @@ export const revalidateConfig = {
budgets: ["/budgets"],
payers: ["/payers"],
notes: ["/notes", "/notes/archived", "/dashboard"],
notifications: ["/dashboard"],
transactions: ["/transactions", "/accounts"],
inbox: ["/inbox", "/transactions", "/dashboard"],
} as const;
@@ -43,6 +44,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"budgets",
"payers",
"notes",
"notifications",
"inbox",
"recurring",
]);

View File

@@ -149,7 +149,7 @@ export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
{ label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" },
{ label: "Anexo", value: "RiAttachment2" },
{ label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
@@ -327,7 +327,7 @@ export const CATEGORY_ICON_GROUPS: CategoryIconGroup[] = [
{ label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" },
{ label: "Anexo", value: "RiAttachment2" },
{ label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },

View File

@@ -1,4 +1,4 @@
import type { ImportStatement, ImportedTransaction } from "./types";
import type { ImportedTransaction, ImportStatement } from "./types";
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
function getField(block: string, tag: string): string | null {

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