22 Commits

Author SHA1 Message Date
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
90 changed files with 13242 additions and 6402 deletions

View File

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

@@ -5,6 +5,67 @@ 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/), 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/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [Unreleased]
## [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 ## [2.0.0] - 2026-03-21
### Adicionado ### Adicionado

View File

@@ -18,6 +18,7 @@
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog. 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
--- ---

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.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/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -32,6 +32,7 @@
- [Início Rápido (manual)](#-início-rápido) - [Início Rápido (manual)](#-início-rápido)
- [Scripts Disponíveis](#-scripts-disponíveis) - [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker) - [Docker](#-docker)
- [Storage S3 Compatível](#-storage-s3-compatível)
- [Variáveis de Ambiente](#-variáveis-de-ambiente) - [Variáveis de Ambiente](#-variáveis-de-ambiente)
- [Arquitetura](#-arquitetura) - [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo) - [Contribuindo](#-contribuindo)
@@ -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 ## 🔐 Variáveis de Ambiente
Copie `.env.example` para `.env` e configure: Copie `.env.example` para `.env` e configure:
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
POSTGRES_PASSWORD=openmonetis_dev_password POSTGRES_PASSWORD=openmonetis_dev_password
POSTGRES_DB=openmonetis_db 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) # Multi-domínio (landing-only no domínio público)
# PUBLIC_DOMAIN=openmonetis.com # 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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

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

View File

@@ -93,12 +93,8 @@
"name": "account_userId_user_id_fk", "name": "account_userId_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -213,12 +209,8 @@
"name": "tokens_api_user_id_user_id_fk", "name": "tokens_api_user_id_user_id_fk",
"tableFrom": "tokens_api", "tableFrom": "tokens_api",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -327,12 +319,8 @@
"name": "orcamentos_user_id_user_id_fk", "name": "orcamentos_user_id_user_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -340,12 +328,8 @@
"name": "orcamentos_categoria_id_categorias_id_fk", "name": "orcamentos_categoria_id_categorias_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -463,12 +447,8 @@
"name": "cartoes_user_id_user_id_fk", "name": "cartoes_user_id_user_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -476,12 +456,8 @@
"name": "cartoes_conta_id_contas_id_fk", "name": "cartoes_conta_id_contas_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -563,12 +539,8 @@
"name": "categorias_user_id_user_id_fk", "name": "categorias_user_id_user_id_fk",
"tableFrom": "categorias", "tableFrom": "categorias",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -683,12 +655,8 @@
"name": "contas_user_id_user_id_fk", "name": "contas_user_id_user_id_fk",
"tableFrom": "contas", "tableFrom": "contas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -847,12 +815,8 @@
"name": "pre_lancamentos_user_id_user_id_fk", "name": "pre_lancamentos_user_id_user_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -860,12 +824,8 @@
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk", "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1005,12 +965,8 @@
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk", "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1018,12 +974,8 @@
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk", "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1031,12 +983,8 @@
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk", "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1044,12 +992,8 @@
"name": "antecipacoes_parcelas_user_id_user_id_fk", "name": "antecipacoes_parcelas_user_id_user_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1179,12 +1123,8 @@
"name": "faturas_user_id_user_id_fk", "name": "faturas_user_id_user_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1192,12 +1132,8 @@
"name": "faturas_cartao_id_cartoes_id_fk", "name": "faturas_cartao_id_cartoes_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -1271,12 +1207,8 @@
"name": "anotacoes_user_id_user_id_fk", "name": "anotacoes_user_id_user_id_fk",
"tableFrom": "anotacoes", "tableFrom": "anotacoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1364,12 +1296,8 @@
"name": "passkey_userId_user_id_fk", "name": "passkey_userId_user_id_fk",
"tableFrom": "passkey", "tableFrom": "passkey",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1452,12 +1380,8 @@
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk", "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1465,12 +1389,8 @@
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk", "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["shared_with_user_id"],
"shared_with_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1478,12 +1398,8 @@
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk", "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["created_by_user_id"],
"created_by_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1639,12 +1555,8 @@
"name": "pagadores_user_id_user_id_fk", "name": "pagadores_user_id_user_id_fk",
"tableFrom": "pagadores", "tableFrom": "pagadores",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1733,12 +1645,8 @@
"name": "insights_salvos_user_id_user_id_fk", "name": "insights_salvos_user_id_user_id_fk",
"tableFrom": "insights_salvos", "tableFrom": "insights_salvos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1808,12 +1716,8 @@
"name": "session_userId_user_id_fk", "name": "session_userId_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1823,9 +1727,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2228,12 +2130,8 @@
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk", "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "antecipacoes_parcelas", "tableTo": "antecipacoes_parcelas",
"columnsFrom": [ "columnsFrom": ["antecipacao_id"],
"antecipacao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2241,12 +2139,8 @@
"name": "lancamentos_user_id_user_id_fk", "name": "lancamentos_user_id_user_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2254,12 +2148,8 @@
"name": "lancamentos_cartao_id_cartoes_id_fk", "name": "lancamentos_cartao_id_cartoes_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2267,12 +2157,8 @@
"name": "lancamentos_conta_id_contas_id_fk", "name": "lancamentos_conta_id_contas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2280,12 +2166,8 @@
"name": "lancamentos_categoria_id_categorias_id_fk", "name": "lancamentos_categoria_id_categorias_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2293,12 +2175,8 @@
"name": "lancamentos_pagador_id_pagadores_id_fk", "name": "lancamentos_pagador_id_pagadores_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -2363,9 +2241,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2443,12 +2319,8 @@
"name": "preferencias_usuario_user_id_user_id_fk", "name": "preferencias_usuario_user_id_user_id_fk",
"tableFrom": "preferencias_usuario", "tableFrom": "preferencias_usuario",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2458,9 +2330,7 @@
"preferencias_usuario_user_id_unique": { "preferencias_usuario_user_id_unique": {
"name": "preferencias_usuario_user_id_unique", "name": "preferencias_usuario_user_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id"]
"user_id"
]
} }
}, },
"policies": {}, "policies": {},

View File

@@ -93,12 +93,8 @@
"name": "account_userId_user_id_fk", "name": "account_userId_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -213,12 +209,8 @@
"name": "tokens_api_user_id_user_id_fk", "name": "tokens_api_user_id_user_id_fk",
"tableFrom": "tokens_api", "tableFrom": "tokens_api",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -327,12 +319,8 @@
"name": "orcamentos_user_id_user_id_fk", "name": "orcamentos_user_id_user_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -340,12 +328,8 @@
"name": "orcamentos_categoria_id_categorias_id_fk", "name": "orcamentos_categoria_id_categorias_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -463,12 +447,8 @@
"name": "cartoes_user_id_user_id_fk", "name": "cartoes_user_id_user_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -476,12 +456,8 @@
"name": "cartoes_conta_id_contas_id_fk", "name": "cartoes_conta_id_contas_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -563,12 +539,8 @@
"name": "categorias_user_id_user_id_fk", "name": "categorias_user_id_user_id_fk",
"tableFrom": "categorias", "tableFrom": "categorias",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -683,12 +655,8 @@
"name": "contas_user_id_user_id_fk", "name": "contas_user_id_user_id_fk",
"tableFrom": "contas", "tableFrom": "contas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -847,12 +815,8 @@
"name": "pre_lancamentos_user_id_user_id_fk", "name": "pre_lancamentos_user_id_user_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -860,12 +824,8 @@
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk", "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1005,12 +965,8 @@
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk", "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1018,12 +974,8 @@
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk", "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1031,12 +983,8 @@
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk", "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1044,12 +992,8 @@
"name": "antecipacoes_parcelas_user_id_user_id_fk", "name": "antecipacoes_parcelas_user_id_user_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1179,12 +1123,8 @@
"name": "faturas_user_id_user_id_fk", "name": "faturas_user_id_user_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1192,12 +1132,8 @@
"name": "faturas_cartao_id_cartoes_id_fk", "name": "faturas_cartao_id_cartoes_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -1271,12 +1207,8 @@
"name": "anotacoes_user_id_user_id_fk", "name": "anotacoes_user_id_user_id_fk",
"tableFrom": "anotacoes", "tableFrom": "anotacoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1364,12 +1296,8 @@
"name": "passkey_userId_user_id_fk", "name": "passkey_userId_user_id_fk",
"tableFrom": "passkey", "tableFrom": "passkey",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1452,12 +1380,8 @@
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk", "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1465,12 +1389,8 @@
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk", "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["shared_with_user_id"],
"shared_with_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1478,12 +1398,8 @@
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk", "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["created_by_user_id"],
"created_by_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1639,12 +1555,8 @@
"name": "pagadores_user_id_user_id_fk", "name": "pagadores_user_id_user_id_fk",
"tableFrom": "pagadores", "tableFrom": "pagadores",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1733,12 +1645,8 @@
"name": "insights_salvos_user_id_user_id_fk", "name": "insights_salvos_user_id_user_id_fk",
"tableFrom": "insights_salvos", "tableFrom": "insights_salvos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1808,12 +1716,8 @@
"name": "session_userId_user_id_fk", "name": "session_userId_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1823,9 +1727,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2228,12 +2130,8 @@
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk", "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "antecipacoes_parcelas", "tableTo": "antecipacoes_parcelas",
"columnsFrom": [ "columnsFrom": ["antecipacao_id"],
"antecipacao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2241,12 +2139,8 @@
"name": "lancamentos_user_id_user_id_fk", "name": "lancamentos_user_id_user_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2254,12 +2148,8 @@
"name": "lancamentos_cartao_id_cartoes_id_fk", "name": "lancamentos_cartao_id_cartoes_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2267,12 +2157,8 @@
"name": "lancamentos_conta_id_contas_id_fk", "name": "lancamentos_conta_id_contas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2280,12 +2166,8 @@
"name": "lancamentos_categoria_id_categorias_id_fk", "name": "lancamentos_categoria_id_categorias_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -2293,12 +2175,8 @@
"name": "lancamentos_pagador_id_pagadores_id_fk", "name": "lancamentos_pagador_id_pagadores_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -2363,9 +2241,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2443,12 +2319,8 @@
"name": "preferencias_usuario_user_id_user_id_fk", "name": "preferencias_usuario_user_id_user_id_fk",
"tableFrom": "preferencias_usuario", "tableFrom": "preferencias_usuario",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2458,9 +2330,7 @@
"preferencias_usuario_user_id_unique": { "preferencias_usuario_user_id_unique": {
"name": "preferencias_usuario_user_id_unique", "name": "preferencias_usuario_user_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id"]
"user_id"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2552,12 +2422,8 @@
"name": "import_category_mappings_user_id_user_id_fk", "name": "import_category_mappings_user_id_user_id_fk",
"tableFrom": "import_category_mappings", "tableFrom": "import_category_mappings",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2565,12 +2431,8 @@
"name": "import_category_mappings_category_id_categorias_id_fk", "name": "import_category_mappings_category_id_categorias_id_fk",
"tableFrom": "import_category_mappings", "tableFrom": "import_category_mappings",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2578,10 +2440,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"import_category_mappings_user_id_description_key_pk": { "import_category_mappings_user_id_description_key_pk": {
"name": "import_category_mappings_user_id_description_key_pk", "name": "import_category_mappings_user_id_description_key_pk",
"columns": [ "columns": ["user_id", "description_key"]
"user_id",
"description_key"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,13 @@
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
} }
] ]
} }

View File

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

1616
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

View File

@@ -81,7 +81,7 @@ fi
# Extrai dados puros do dump custom (sem nova conexão ao banco) # Extrai dados puros do dump custom (sem nova conexão ao banco)
pg_restore --data-only --schema=public --no-owner --no-privileges \ 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)" 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() { function printUsage() {
console.log(` console.log(`
Uso: Uso:
pnpm seed:empty-account -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}] pnpm mockup -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
Exemplos: Exemplos:
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2026-01 pnpm mockup -- --userId=user_123 --startPeriod=2026-01
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2025-10 --months=8 pnpm mockup -- --userId=user_123 --startPeriod=2025-10 --months=8
`); `);
} }
@@ -766,11 +766,12 @@ async function seedInvoicesForCards(params: {
} }
async function main() { async function main() {
const options = parseArgs(process.argv.slice(2));
if (!process.env.DATABASE_URL) { if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL não está configurada no ambiente."); throw new Error("DATABASE_URL não está configurada no ambiente.");
} }
const options = parseArgs(process.argv.slice(2));
const logoOptions = await loadLogoOptions(); const logoOptions = await loadLogoOptions();
const avatarOptions = await loadAvatarOptions(); const avatarOptions = await loadAvatarOptions();
const businessToday = getBusinessTodayInfo(); const businessToday = getBusinessTodayInfo();
@@ -794,9 +795,8 @@ async function main() {
throw new Error(`Usuário ${options.userId} não foi encontrado.`); throw new Error(`Usuário ${options.userId} não foi encontrado.`);
} }
await ensureCategories(targetUser.id);
const adminPayer = await ensureAdminPayer(targetUser);
await assertFinancialSpaceIsEmpty(targetUser.id); await assertFinancialSpaceIsEmpty(targetUser.id);
const adminPayer = await ensureAdminPayer(targetUser);
const categoriesByName = await ensureCategories(targetUser.id); const categoriesByName = await ensureCategories(targetUser.id);

View File

@@ -1,6 +1,7 @@
import { InboxPage } from "@/features/inbox/components/inbox-page"; import { InboxPage } from "@/features/inbox/components/inbox-page";
import { import {
type ResolvedInboxSearchParams, type ResolvedInboxSearchParams,
resolveInboxApp,
resolveInboxPagination, resolveInboxPagination,
resolveInboxStatus, resolveInboxStatus,
} from "@/features/inbox/page-helpers"; } from "@/features/inbox/page-helpers";
@@ -8,6 +9,7 @@ import {
fetchAppLogoMap, fetchAppLogoMap,
fetchInboxDialogData, fetchInboxDialogData,
fetchInboxItemsPage, fetchInboxItemsPage,
fetchInboxSourceApps,
fetchInboxStatusCounts, fetchInboxStatusCounts,
} from "@/features/inbox/queries"; } from "@/features/inbox/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
@@ -32,21 +34,31 @@ export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams); const activeStatus = resolveInboxStatus(resolvedSearchParams);
const activeApp = resolveInboxApp(resolvedSearchParams);
const paginationInput = resolveInboxPagination(resolvedSearchParams); const paginationInput = resolveInboxPagination(resolvedSearchParams);
const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([ const [itemsPage, counts, sourceApps, dialogData, appLogoMap] =
fetchInboxItemsPage(userId, activeStatus, paginationInput), await Promise.all([
fetchInboxItemsPage(userId, activeStatus, {
...paginationInput,
sourceApp: activeApp,
}),
fetchInboxStatusCounts(userId), fetchInboxStatusCounts(userId),
fetchInboxSourceApps(userId, activeStatus).catch(() => [] as string[]),
activeStatus === "pending" activeStatus === "pending"
? fetchInboxDialogData(userId) ? fetchInboxDialogData(userId)
: Promise.resolve(EMPTY_DIALOG_DATA), : Promise.resolve(EMPTY_DIALOG_DATA),
fetchAppLogoMap(userId), fetchAppLogoMap(userId),
]); ]);
const normalizedSourceApps = Array.isArray(sourceApps) ? sourceApps : [];
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<InboxPage <InboxPage
activeStatus={activeStatus} activeStatus={activeStatus}
activeApp={activeApp}
sourceApps={normalizedSourceApps}
items={itemsPage.items} items={itemsPage.items}
counts={counts} counts={counts}
pagination={itemsPage.pagination} 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 { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern"; import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server"; import { getUserSession } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
searchParams,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}>) { }>) {
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id);
// 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,
);
return ( return (
<PrivacyProvider> <PrivacyProvider>
@@ -40,7 +21,7 @@ export default async function DashboardLayout({
notificationsSnapshot={navbarData.notificationsSnapshot} notificationsSnapshot={navbarData.notificationsSnapshot}
/> />
<div className="relative flex flex-1 flex-col pt-16"> <div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden"> <div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
<DotPattern <DotPattern
width={20} width={20}
height={20} height={20}

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="pt-BR" lang="pt-BR"
className={`${america.variable} ${america.className}`} className={`${america.variable} ${america.className} `}
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>

View File

@@ -132,8 +132,6 @@ export const userPreferences = pgTable("preferencias_usuario", {
statementNoteAsColumn: boolean("extrato_note_as_column") statementNoteAsColumn: boolean("extrato_note_as_column")
.notNull() .notNull()
.default(false), .default(false),
systemFont: text("system_font").notNull().default("ai-sans"),
moneyFont: text("money_font").notNull().default("ai-sans"),
transactionsColumnOrder: jsonb("lancamentos_column_order").$type< transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null 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( export const installmentAnticipations = pgTable(
"antecipacoes_parcelas", "antecipacoes_parcelas",
{ {
@@ -815,7 +847,9 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
}), }),
})); }));
export const transactionsRelations = relations(transactions, ({ one }) => ({ export const transactionsRelations = relations(
transactions,
({ one, many }) => ({
user: one(user, { user: one(user, {
fields: [transactions.userId], fields: [transactions.userId],
references: [user.id], references: [user.id],
@@ -840,7 +874,9 @@ export const transactionsRelations = relations(transactions, ({ one }) => ({
fields: [transactions.anticipationId], fields: [transactions.anticipationId],
references: [installmentAnticipations.id], references: [installmentAnticipations.id],
}), }),
})); transactionAttachments: many(transactionAttachments),
}),
);
export const installmentAnticipationsRelations = relations( export const installmentAnticipationsRelations = relations(
installmentAnticipations, 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( export const importCategoryMappings = pgTable(
"import_category_mappings", "import_category_mappings",
{ {
@@ -907,3 +977,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
export type InboxItem = typeof inboxItems.$inferSelect; export type InboxItem = typeof inboxItems.$inferSelect;
export type NewInboxItem = typeof inboxItems.$inferInsert; export type NewInboxItem = typeof inboxItems.$inferInsert;
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect; 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,8 +48,7 @@ const accountBaseSchema = z.object({
.string({ message: "Selecione um logo." }) .string({ message: "Selecione um logo." })
.trim() .trim()
.min(1, "Selecione um logo."), .min(1, "Selecione um logo."),
initialBalance: z initialBalance: z.union([
.union([
z.number(), z.number(),
z z
.string() .string()

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 { return {
categories, categories: filtered,
currentTotal, currentTotal,
previousTotal, previousTotal,
}; };

View File

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

View File

@@ -4,6 +4,7 @@ import { payers } from "@/db/schema";
import { fetchPendingInboxCount } from "@/features/inbox/queries"; import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getBusinessDateString } from "@/shared/utils/date";
import { import {
type DashboardNotificationsSnapshot, type DashboardNotificationsSnapshot,
fetchDashboardNotifications, fetchDashboardNotifications,
@@ -36,8 +37,8 @@ async function fetchAdminPayerAvatarUrl(
async function fetchDashboardNavbarDataInternal( async function fetchDashboardNavbarDataInternal(
userId: string, userId: string,
currentPeriod: string,
): Promise<DashboardNavbarData> { ): Promise<DashboardNavbarData> {
const currentPeriod = getBusinessDateString().slice(0, 7);
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] = const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
await Promise.all([ await Promise.all([
fetchAdminPayerAvatarUrl(userId), fetchAdminPayerAvatarUrl(userId),
@@ -52,12 +53,11 @@ async function fetchDashboardNavbarDataInternal(
}; };
} }
export function fetchDashboardNavbarData( export function fetchDashboardNavbarData(userId: string) {
userId: string, const currentPeriod = getBusinessDateString().slice(0, 7);
currentPeriod: string,
) {
return unstable_cache( return unstable_cache(
() => fetchDashboardNavbarDataInternal(userId, currentPeriod), () => fetchDashboardNavbarDataInternal(userId),
[`dashboard-navbar-${userId}-${currentPeriod}`], [`dashboard-navbar-${userId}-${currentPeriod}`],
{ {
tags: [`dashboard-${userId}`], 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"; "use server";
import { and, eq, lt, ne, sql } from "drizzle-orm"; import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
import { import {
budgets, budgets,
cards, cards,
categories, categories,
dashboardNotificationStates,
invoices, invoices,
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; 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 { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import type {
BudgetNotification,
DashboardNotification,
DashboardNotificationsSnapshot,
} from "@/shared/lib/types/notifications";
import { import {
buildDateOnlyStringFromPeriodDay, buildDateOnlyStringFromPeriodDay,
getBusinessDateString, getBusinessDateString,
@@ -19,41 +27,65 @@ import {
toDateOnlyString, toDateOnlyString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
import { formatPeriodForUrl } from "@/shared/utils/period";
export type NotificationType = "overdue" | "due_soon"; export type {
BudgetNotification,
export type DashboardNotification = { BudgetStatus,
id: string; DashboardNotification,
type: "invoice" | "boleto"; DashboardNotificationsSnapshot,
name: string; NotificationType,
dueDate: string; } from "@/shared/lib/types/notifications";
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[];
};
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80; 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: * Busca todas as notificações do dashboard:
* - Faturas de cartão atrasadas ou com vencimento próximo * - Faturas de cartão atrasadas ou com vencimento próximo
@@ -188,7 +220,9 @@ export async function fetchDashboardNotifications(
db db
.select({ .select({
orcamentoId: budgets.id, orcamentoId: budgets.id,
categoryId: budgets.categoryId,
budgetAmount: budgets.amount, budgetAmount: budgets.amount,
period: budgets.period,
categoriaName: categories.name, categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`, spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
}) })
@@ -216,12 +250,12 @@ export async function fetchDashboardNotifications(
); );
if (!dueDate) continue; if (!dueDate) continue;
const amount = toNumber(invoice.totalAmount); const amount = toNumber(invoice.totalAmount);
const notificationId = invoice.invoiceId const notificationKey = buildInvoiceNotificationKey(
? `invoice-${invoice.invoiceId}` invoice.cardId,
: `invoice-${invoice.cardId}-${invoice.period}`; invoice.period,
);
notifications.push({ notifications.push({
id: notificationId,
type: "invoice", type: "invoice",
name: invoice.cardName, name: invoice.cardName,
dueDate, dueDate,
@@ -230,6 +264,13 @@ export async function fetchDashboardNotifications(
period: invoice.period, period: invoice.period,
showAmount: true, showAmount: true,
cardLogo: invoice.cardLogo, 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; if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon";
? `invoice-${invoice.invoiceId}` const notificationKey = buildInvoiceNotificationKey(
: `invoice-${invoice.cardId}-${invoice.period}`; invoice.cardId,
invoice.period,
);
notifications.push({ notifications.push({
id: notificationId,
type: "invoice", type: "invoice",
name: invoice.cardName, name: invoice.cardName,
dueDate, dueDate,
status: invoiceIsOverdue ? "overdue" : "due_soon", status: notificationStatus,
amount: Math.abs(amount), amount: Math.abs(amount),
period: invoice.period, period: invoice.period,
showAmount: invoiceIsOverdue, showAmount: invoiceIsOverdue,
cardLogo: invoice.cardLogo, 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 isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod; const isCurrentPeriod = boleto.period === currentPeriod;
const amount = toNumber(boleto.amount); const amount = toNumber(boleto.amount);
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
const notificationKey = buildBoletoNotificationKey(boleto.id);
if (isOldPeriod) { if (isOldPeriod) {
notifications.push({ notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto", type: "boleto",
name: boleto.name, name: boleto.name,
dueDate, dueDate,
@@ -303,17 +353,32 @@ export async function fetchDashboardNotifications(
amount: Math.abs(amount), amount: Math.abs(amount),
period: boleto.period, period: boleto.period,
showAmount: true, showAmount: true,
notificationKey,
fingerprint: "overdue",
href,
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
}); });
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) { } else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon";
notifications.push({ notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto", type: "boleto",
name: boleto.name, name: boleto.name,
dueDate, dueDate,
status: boletoIsOverdue ? "overdue" : "due_soon", status: notificationStatus,
amount: Math.abs(amount), amount: Math.abs(amount),
period: boleto.period, period: boleto.period,
showAmount: boletoIsOverdue, 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; const usedPercentage = (spentAmount / budgetAmount) * 100;
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue; if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical";
const notificationKey = buildBudgetNotificationKey(
row.categoryId,
row.orcamentoId,
row.period,
);
budgetNotifications.push({ budgetNotifications.push({
id: `budget-${row.orcamentoId}`,
categoryName: row.categoriaName, categoryName: row.categoriaName,
budgetAmount, budgetAmount,
spentAmount, spentAmount,
usedPercentage, 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 b.usedPercentage - a.usedPercentage;
}); });
return { const notificationKeys = [
notifications, ...notifications.map((notification) => notification.notificationKey),
totalCount: notifications.length, ...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, 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 = () => export const excludeInitialBalanceWhenConfigured = () =>
or( or(
isNull(transactions.note),
ne(transactions.note, INITIAL_BALANCE_NOTE), ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome), isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false), eq(financialAccounts.excludeInitialBalanceFromIncome, false),

View File

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

View File

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

View File

@@ -6,10 +6,20 @@ import {
RiArrowRightDoubleLine, RiArrowRightDoubleLine,
RiArrowRightSLine, RiArrowRightSLine,
RiAtLine, RiAtLine,
RiCalendarEventLine,
RiDeleteBinLine, RiDeleteBinLine,
} from "@remixicon/react"; } 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 { 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 { toast } from "sonner";
import { import {
bulkDeleteInboxItemsAction, bulkDeleteInboxItemsAction,
@@ -42,6 +52,7 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@/shared/components/ui/tabs"; } from "@/shared/components/ui/tabs";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { InboxCard } from "./inbox-card"; import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog"; import { InboxDetailsDialog } from "./inbox-details-dialog";
import type { import type {
@@ -52,8 +63,80 @@ import type {
SelectOption, SelectOption,
} from "./types"; } 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 { interface InboxPageProps {
activeStatus: InboxStatus; activeStatus: InboxStatus;
activeApp: string | null;
sourceApps: string[];
items: InboxItem[]; items: InboxItem[];
counts: InboxStatusCounts; counts: InboxStatusCounts;
pagination: InboxPaginationState; pagination: InboxPaginationState;
@@ -69,6 +152,8 @@ interface InboxPageProps {
export function InboxPage({ export function InboxPage({
activeStatus, activeStatus,
activeApp,
sourceApps = [],
items, items,
counts, counts,
pagination, pagination,
@@ -111,6 +196,38 @@ export function InboxPage({
const [selectionBulkStatus, setSelectionBulkStatus] = const [selectionBulkStatus, setSelectionBulkStatus] =
useState<InboxStatus>("pending"); 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) => { const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open); setProcessOpen(open);
if (!open) { if (!open) {
@@ -132,20 +249,20 @@ export function InboxPage({
} }
}; };
const handleProcessRequest = (item: InboxItem) => { const handleProcessRequest = useCallback((item: InboxItem) => {
setItemToProcess(item); setItemToProcess(item);
setProcessOpen(true); setProcessOpen(true);
}; }, []);
const handleDetailsRequest = (item: InboxItem) => { const handleDetailsRequest = useCallback((item: InboxItem) => {
setItemDetails(item); setItemDetails(item);
setDetailsOpen(true); setDetailsOpen(true);
}; }, []);
const handleDiscardRequest = (item: InboxItem) => { const handleDiscardRequest = useCallback((item: InboxItem) => {
setItemToDiscard(item); setItemToDiscard(item);
setDiscardOpen(true); setDiscardOpen(true);
}; }, []);
const handleDiscardConfirm = async () => { const handleDiscardConfirm = async () => {
if (!itemToDiscard) return; if (!itemToDiscard) return;
@@ -170,10 +287,10 @@ export function InboxPage({
} }
}; };
const handleDeleteRequest = (item: InboxItem) => { const handleDeleteRequest = useCallback((item: InboxItem) => {
setItemToDelete(item); setItemToDelete(item);
setDeleteOpen(true); setDeleteOpen(true);
}; }, []);
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!itemToDelete) return; if (!itemToDelete) return;
@@ -198,10 +315,10 @@ export function InboxPage({
} }
}; };
const handleRestoreRequest = (item: InboxItem) => { const handleRestoreRequest = useCallback((item: InboxItem) => {
setItemToRestore(item); setItemToRestore(item);
setRestoreOpen(true); setRestoreOpen(true);
}; }, []);
const handleRestoreToPendingConfirm = async () => { const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return; if (!itemToRestore) return;
@@ -224,13 +341,13 @@ export function InboxPage({
setSelectedIds((current) => current.filter((id) => visibleIds.has(id))); setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [items]); }, [items]);
const toggleSelection = (id: string) => { const toggleSelection = useCallback((id: string) => {
setSelectedIds((current) => setSelectedIds((current) =>
current.includes(id) current.includes(id)
? current.filter((value) => value !== id) ? current.filter((value) => value !== id)
: [...current, id], : [...current, id],
); );
}; }, []);
const allSelected = items.length > 0 && selectedIds.length === items.length; const allSelected = items.length > 0 && selectedIds.length === items.length;
@@ -239,7 +356,6 @@ export function InboxPage({
setSelectedIds([]); setSelectedIds([]);
return; return;
} }
setSelectedIds(items.map((item) => item.id)); 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) => { 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) => { const handleSelectionBulkRequest = (status: InboxStatus) => {
@@ -401,16 +551,30 @@ export function InboxPage({
</Card> </Card>
); );
const renderGrid = (list: InboxItem[], readonly?: boolean) => const renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => {
list.length === 0 ? ( if (list.length === 0) {
renderEmptyState( if (activeApp) {
return renderEmptyState("Nenhuma notificação deste app");
}
return renderEmptyState(
readonly readonly
? "Nenhuma notificação nesta aba" ? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente", : "Nenhum pré-lançamento pendente",
) );
) : ( }
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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{list.map((item) => ( {group.items.map((item) => (
<InboxCard <InboxCard
key={item.id} key={item.id}
item={item} item={item}
@@ -420,13 +584,72 @@ export function InboxPage({
onDiscard={readonly ? undefined : handleDiscardRequest} onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest} onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined} onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={readonly ? handleRestoreRequest : undefined} onRestoreToPending={
readonly ? handleRestoreRequest : undefined
}
selected={selectedIds.includes(item.id)} selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection} onSelectToggle={toggleSelection}
/> />
))} ))}
</div> </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 ( return (
<> <>
@@ -463,9 +686,17 @@ export function InboxPage({
</TabsList> </TabsList>
<TabsContent value="pending" className="mt-4"> <TabsContent value="pending" className="mt-4">
{activeStatus === "pending" && items.length > 0 && ( {activeStatus === "pending" &&
<div className="mb-4 flex items-center justify-end gap-2"> (appFilterOptions.length > 0 || items.length > 0) && (
<Button variant="outline" size="sm" onClick={toggleSelectAll}> <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"} {allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button> </Button>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
@@ -479,13 +710,23 @@ export function InboxPage({
</Button> </Button>
)} )}
</div> </div>
) : null}
</div>
)} )}
{activeStatus === "pending" ? renderGrid(items, false) : null} {activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
</TabsContent> </TabsContent>
<TabsContent value="processed" className="mt-4"> <TabsContent value="processed" className="mt-4">
{activeStatus === "processed" && items.length > 0 && ( {activeStatus === "processed" &&
<div className="mb-4 flex items-center justify-end gap-2"> (appFilterOptions.length > 0 || items.length > 0) && (
<Button variant="outline" size="sm" onClick={toggleSelectAll}> <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"} {allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button> </Button>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
@@ -507,13 +748,23 @@ export function InboxPage({
Limpar processados Limpar processados
</Button> </Button>
</div> </div>
) : null}
</div>
)} )}
{activeStatus === "processed" ? renderGrid(items, true) : null} {activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
</TabsContent> </TabsContent>
<TabsContent value="discarded" className="mt-4"> <TabsContent value="discarded" className="mt-4">
{activeStatus === "discarded" && items.length > 0 && ( {activeStatus === "discarded" &&
<div className="mb-4 flex items-center justify-end gap-2"> (appFilterOptions.length > 0 || items.length > 0) && (
<Button variant="outline" size="sm" onClick={toggleSelectAll}> <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"} {allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button> </Button>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
@@ -535,8 +786,10 @@ export function InboxPage({
Limpar descartados Limpar descartados
</Button> </Button>
</div> </div>
) : null}
</div>
)} )}
{activeStatus === "discarded" ? renderGrid(items, true) : null} {activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

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

View File

@@ -39,18 +39,26 @@ export async function fetchInboxItemsPage(
{ {
page, page,
pageSize, pageSize,
sourceApp,
}: { }: {
page: number; page: number;
pageSize: number; pageSize: number;
sourceApp?: string | null;
}, },
): Promise<{ ): Promise<{
items: InboxItem[]; items: InboxItem[];
pagination: InboxPaginationState; pagination: InboxPaginationState;
}> { }> {
const where = and(
eq(inboxItems.userId, userId),
eq(inboxItems.status, status),
sourceApp ? eq(inboxItems.sourceAppName, sourceApp) : undefined,
);
const [countRow] = await db const [countRow] = await db
.select({ total: count() }) .select({ total: count() })
.from(inboxItems) .from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); .where(where);
const totalItems = Number(countRow?.total ?? 0); const totalItems = Number(countRow?.total ?? 0);
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1); const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
@@ -60,7 +68,7 @@ export async function fetchInboxItemsPage(
const items = await db const items = await db
.select() .select()
.from(inboxItems) .from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) .where(where)
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt)) .orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .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( export async function fetchInboxStatusCounts(
userId: string, userId: string,
): Promise<InboxStatusCounts> { ): Promise<InboxStatusCounts> {

View File

@@ -7,6 +7,7 @@ import {
RiDatabase2Line, RiDatabase2Line,
RiDeviceLine, RiDeviceLine,
RiDownloadCloudLine, RiDownloadCloudLine,
RiErrorWarningLine,
RiEyeOffLine, RiEyeOffLine,
RiFileTextLine, RiFileTextLine,
RiFlashlightLine, RiFlashlightLine,
@@ -260,6 +261,20 @@ export const whoIsItForItems: FeatureItem[] = [
"Você vai precisar configurar as coisas, conectar suas contas e ajustar o sistema para o seu jeito de usar.", "Você vai precisar configurar as coisas, conectar suas contas e ajustar o sistema para o seu jeito de usar.",
colorVar: "var(--data-4)", 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) { 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}%`)), not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
), ),
or( or(
isNull(transactions.note),
ne(transactions.note, INITIAL_BALANCE_NOTE), ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome), isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false), eq(financialAccounts.excludeInitialBalanceFromIncome, false),

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, payerId: data.payerId ?? null,
accountId: data.accountId ?? null, accountId: data.accountId ?? null,
cardId: data.cardId ?? null, cardId: data.cardId ?? null,
...(data.isSettled !== undefined && { isSettled: data.isSettled }),
}; };
if (data.amount !== undefined) { 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 { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
// Retorna um map de descriptionKey → categoryId para as descrições fornecidas // Retorna um map de descriptionKey → categoryId para as descrições fornecidas
export async function fetchCategoryMappings( export async function fetchCategoryMappings(
descriptions: string[], descriptions: string[],
@@ -53,7 +52,10 @@ export async function saveCategoryMappings(
.insert(importCategoryMappings) .insert(importCategoryMappings)
.values(toUpsert) .values(toUpsert)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [importCategoryMappings.userId, importCategoryMappings.descriptionKey], target: [
importCategoryMappings.userId,
importCategoryMappings.descriptionKey,
],
set: { set: {
categoryId: sql`excluded.category_id`, categoryId: sql`excluded.category_id`,
updatedAt: sql`excluded.updated_at`, updatedAt: sql`excluded.updated_at`,

View File

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

View File

@@ -29,7 +29,11 @@ const importSchema = z.object({
accountId: uuidSchema("FinancialAccount").nullable().optional(), accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(), cardId: uuidSchema("Cartão").nullable().optional(),
paymentMethod: z.string().min(1), 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>; export type ImportRow = z.infer<typeof importRowSchema>;
@@ -51,10 +55,7 @@ export async function checkDuplicateFitIds(
.select({ ofxFitId: transactions.ofxFitId }) .select({ ofxFitId: transactions.ofxFitId })
.from(transactions) .from(transactions)
.where( .where(
and( and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
eq(transactions.userId, userId),
inArray(transactions.ofxFitId, ids),
),
); );
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null); 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); const parsed = importSchema.safeParse(input);
if (!parsed.success) { 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 // Valida ownership
const [payerOk, accountOk, cardOk] = await Promise.all([ const [payerOk, accountOk, cardOk] = await Promise.all([
@@ -94,14 +99,19 @@ export async function importTransactionsAction(
const records = rows.map((row) => { const records = rows.map((row) => {
const purchaseDate = parseLocalDateString(row.date); 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 { return {
name: row.description, name: row.description,
transactionType: row.transactionType === "income" ? "Receita" : "Despesa", transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
condition: "À vista" as const, condition: "À vista" as const,
paymentMethod, paymentMethod,
amount: (row.transactionType === "expense" ? -row.amount : row.amount).toFixed(2), amount: (row.transactionType === "expense"
? -row.amount
: row.amount
).toFixed(2),
purchaseDate, purchaseDate,
period, period,
isSettled, isSettled,
@@ -143,10 +153,7 @@ export async function deleteTransactionByFitId(
await db await db
.delete(transactions) .delete(transactions)
.where( .where(
and( and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
eq(transactions.userId, userId),
eq(transactions.ofxFitId, fitId),
),
); );
await revalidateForEntity("transactions", userId); await revalidateForEntity("transactions", userId);

View File

@@ -1,11 +1,18 @@
"use server"; "use server";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema"; import {
attachments,
financialAccounts,
invoices,
transactionAttachments,
transactions,
} from "@/db/schema";
import { handleActionError } from "@/shared/lib/actions/helpers"; import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { import {
buildEntriesByPayer, buildEntriesByPayer,
sendPayerAutoEmails, sendPayerAutoEmails,
@@ -16,6 +23,8 @@ import {
getBusinessTodayDate, getBusinessTodayDate,
parseLocalDateString, parseLocalDateString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { MONTH_NAMES } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import { import {
buildLancamentoRecords, buildLancamentoRecords,
buildShares, buildShares,
@@ -37,7 +46,7 @@ import {
export async function createTransactionAction( export async function createTransactionAction(
input: CreateInput, input: CreateInput,
): Promise<ActionResult> { ): Promise<ActionResult<{ ids: string[] }>> {
try { try {
const user = await getUser(); const user = await getUser();
const data = createSchema.parse(input); 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."); 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( const notificationEntries = buildEntriesByPayer(
records.map((record) => ({ records.map((record) => ({
@@ -128,9 +172,13 @@ export async function createTransactionAction(
revalidate(user.id); 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) { } 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 await db
.delete(transactions) .delete(transactions)
.where( .where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
); );
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
if (existing.payerId) { if (existing.payerId) {
const notificationEntries = buildEntriesByPayer([ 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 { Separator } from "@/shared/components/ui/separator";
import { Spinner } from "@/shared/components/ui/spinner"; import { Spinner } from "@/shared/components/ui/spinner";
import { getTodayDateString } from "@/shared/utils/date"; import { getTodayDateString } from "@/shared/utils/date";
import { createClientSafeId } from "@/shared/utils/id";
import { import {
dateToPeriod, dateToPeriod,
displayPeriod, displayPeriod,
@@ -120,6 +121,19 @@ interface TransactionRow {
payerId: string | undefined; payerId: string | undefined;
} }
function createEmptyTransactionRow(
defaultPayerId?: string | null,
): TransactionRow {
return {
id: createClientSafeId(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
};
}
export function MassAddDialog({ export function MassAddDialog({
open, open,
onOpenChange, onOpenChange,
@@ -153,15 +167,8 @@ export function MassAddDialog({
const isCartaoSelected = paymentMethod === "Cartão de crédito"; const isCartaoSelected = paymentMethod === "Cartão de crédito";
// Transaction rows // Transaction rows
const [transactions, setTransactions] = useState<TransactionRow[]>([ const [transactions, setTransactions] = useState<TransactionRow[]>(() => [
{ createEmptyTransactionRow(defaultPayerId),
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
},
]); ]);
// Categorias agrupadas e filtradas por tipo de transação // Categorias agrupadas e filtradas por tipo de transação
@@ -175,14 +182,7 @@ export function MassAddDialog({
const addTransaction = () => { const addTransaction = () => {
setTransactions([ setTransactions([
...transactions, ...transactions,
{ createEmptyTransactionRow(defaultPayerId),
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
},
]); ]);
}; };
@@ -256,16 +256,7 @@ export function MassAddDialog({
setPeriod(selectedPeriod); setPeriod(selectedPeriod);
setContaId(undefined); setContaId(undefined);
setCartaoId(defaultCardId ?? undefined); setCartaoId(defaultCardId ?? undefined);
setTransactions([ setTransactions([createEmptyTransactionRow(defaultPayerId)]);
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
payerId: defaultPayerId ?? undefined,
},
]);
} catch (_error) { } catch (_error) {
// Error is handled by the onSubmit function // Error is handled by the onSubmit function
} finally { } finally {

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { import {
currencyFormatter, currencyFormatter,
formatCondition, formatCondition,
@@ -21,6 +22,7 @@ import {
import { Separator } from "@/shared/components/ui/separator"; import { Separator } from "@/shared/components/ui/separator";
import { parseLocalDateString } from "@/shared/utils/date"; import { parseLocalDateString } from "@/shared/utils/date";
import { getPaymentMethodIcon } from "@/shared/utils/icons"; import { getPaymentMethodIcon } from "@/shared/utils/icons";
import { AttachmentSection } from "../attachments/attachment-section";
import { InstallmentTimeline } from "../shared/installment-timeline"; import { InstallmentTimeline } from "../shared/installment-timeline";
import type { TransactionItem } from "../types"; import type { TransactionItem } from "../types";
@@ -37,6 +39,12 @@ export function TransactionDetailsDialog({
transaction, transaction,
onEdit, onEdit,
}: TransactionDetailsDialogProps) { }: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
useEffect(() => {
setAttachmentCount(null);
}, [transaction?.id]);
if (!transaction) return null; if (!transaction) return null;
const isInstallment = const isInstallment =
@@ -63,7 +71,7 @@ export function TransactionDetailsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl"> <DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{transaction.name}</DialogTitle> <DialogTitle>{transaction.name}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -71,9 +79,46 @@ export function TransactionDetailsDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[60vh] overflow-y-auto text-sm"> <div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
<div className="grid gap-3"> <div className="min-w-0 space-y-4">
<ul className="grid gap-3"> <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={
transaction.isSettled
? "text-success bg-success/10"
: "text-muted-foreground"
}
>
{transaction.isSettled ? "Pago" : "Pendente"}
</Badge>
</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 <DetailRow
label="Período" label="Período"
value={formatPeriod(transaction.period)} value={formatPeriod(transaction.period)}
@@ -99,41 +144,11 @@ export function TransactionDetailsDialog({
value={transaction.categoriaName ?? "—"} 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"> <li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span> <span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span> <span>{transaction.pagadorName}</span>
</li> </li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Status</span>
<Badge
variant="secondary"
className={
transaction.isSettled
? "text-success bg-success/10"
: "text-muted-foreground"
}
>
{transaction.isSettled ? "Pago" : "Pendente"}
</Badge>
</li>
{isBoleto && transaction.dueDate && ( {isBoleto && transaction.dueDate && (
<DetailRow <DetailRow
label="Vencimento" label="Vencimento"
@@ -147,18 +162,16 @@ export function TransactionDetailsDialog({
<Badge variant="outline">Dividido</Badge> <Badge variant="outline">Dividido</Badge>
</li> </li>
)} )}
{transaction.note && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">Notas</span>
<span className="text-foreground">{transaction.note}</span>
</li>
)}
</ul> </ul>
</section>
<ul className="mb-2 grid gap-3"> <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 && ( {isInstallment && (
<li className="mt-4"> <li className="mb-1">
<InstallmentTimeline <InstallmentTimeline
purchaseDate={parseLocalDateString( purchaseDate={parseLocalDateString(
transaction.purchaseDate, transaction.purchaseDate,
@@ -188,19 +201,40 @@ export function TransactionDetailsDialog({
value={`${transaction.recurrenceCount} meses`} value={`${transaction.recurrenceCount} meses`}
/> />
)} )}
{!isInstallment && <Separator className="my-2" />}
<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> </ul>
</section>
{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>
</div> </div>
<Separator />
<DialogFooter> <DialogFooter>
{onEdit && !transaction.readonly && ( {onEdit && !transaction.readonly && (
<Button variant="outline" onClick={handleEdit}> <Button variant="outline" onClick={handleEdit}>
@@ -223,9 +257,9 @@ interface DetailRowProps {
function DetailRow({ label, value }: DetailRowProps) { function DetailRow({ label, value }: DetailRowProps) {
return ( 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 className="text-muted-foreground">{label}</span>
<span>{value}</span> <span className="min-w-0 truncate">{value}</span>
</li> </li>
); );
} }

View File

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

View File

@@ -6,6 +6,10 @@ import {
createTransactionAction, createTransactionAction,
updateTransactionAction, updateTransactionAction,
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { import {
filterSecondaryPayerOptions, filterSecondaryPayerOptions,
groupAndSortCategories, groupAndSortCategories,
@@ -30,7 +34,10 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { useControlledState } from "@/shared/hooks/use-controlled-state"; 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 { BasicFieldsSection } from "./basic-fields-section";
import { BoletoFieldsSection } from "./boleto-fields-section"; import { BoletoFieldsSection } from "./boleto-fields-section";
import { CategorySection } from "./category-section"; import { CategorySection } from "./category-section";
@@ -90,6 +97,7 @@ export function TransactionDialog({
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
@@ -126,6 +134,7 @@ export function TransactionDialog({
setFormState(initial); setFormState(initial);
setErrorMessage(null); setErrorMessage(null);
setPendingFile(null);
} }
}, [ }, [
dialogOpen, dialogOpen,
@@ -313,6 +322,29 @@ export function TransactionDialog({
const result = await createTransactionAction(payload); const result = await createTransactionAction(payload);
if (result.success) { 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); toast.success(result.message);
onSuccess?.(); onSuccess?.();
setDialogOpen(false); setDialogOpen(false);
@@ -346,6 +378,10 @@ export function TransactionDialog({
mode === "update" && formState.paymentMethod === "Boleto" mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null ? formState.boletoPaymentDate || null
: null, : null,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
}); });
return; return;
} }
@@ -411,18 +447,18 @@ export function TransactionDialog({
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null} {trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent> <DialogContent className="min-w-0 overflow-x-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
className="flex flex-col gap-0" className="flex min-w-0 flex-col gap-0"
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate 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 <BasicFieldsSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
@@ -473,17 +509,31 @@ export function TransactionDialog({
) : null} ) : null}
{isUpdateMode ? ( {isUpdateMode ? (
<>
<NoteSection <NoteSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} 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"> <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" /> <RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
Condições e anotações Condições, anotações e anexos
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3"> <CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
<ConditionSection <ConditionSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
@@ -494,6 +544,10 @@ export function TransactionDialog({
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
/> />
<AttachmentFilePicker
file={pendingFile}
onChange={setPendingFile}
/>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
)} )}

View File

@@ -33,7 +33,8 @@ export function decodeAccountCard(value: string): {
id: string; id: string;
} | null { } | null {
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) }; 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; return null;
} }
@@ -65,7 +66,9 @@ export function GlobalFields({
onBulkCategoryChange, onBulkCategoryChange,
}: GlobalFieldsProps) { }: GlobalFieldsProps) {
const isCard = accountCardValue?.startsWith("card:") ?? false; 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"); const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
return ( return (
@@ -131,7 +134,10 @@ export function GlobalFields({
<SelectContent> <SelectContent>
{payerOptions.map((opt) => ( {payerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<PayerSelectContent label={opt.label} avatarUrl={opt.avatarUrl} /> <PayerSelectContent
label={opt.label}
avatarUrl={opt.avatarUrl}
/>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -150,7 +156,10 @@ export function GlobalFields({
<SelectLabel>Despesa</SelectLabel> <SelectLabel>Despesa</SelectLabel>
{expenseCategories.map((opt) => ( {expenseCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} /> <CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -163,7 +172,10 @@ export function GlobalFields({
<SelectLabel>Receita</SelectLabel> <SelectLabel>Receita</SelectLabel>
{incomeCategories.map((opt) => ( {incomeCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} /> <CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,9 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
} }
onParsed(statement); onParsed(statement);
} catch { } 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"); reader.readAsText(file, "windows-1252");
@@ -119,11 +121,7 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{error ? ( {error ? <p className="text-destructive text-sm">{error}</p> : <span />}
<p className="text-destructive text-sm">{error}</p>
) : (
<span />
)}
<button <button
type="button" type="button"
onClick={handleDownloadTemplate} onClick={handleDownloadTemplate}

View File

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

View File

@@ -6,6 +6,7 @@ import {
RiArrowLeftSLine, RiArrowLeftSLine,
RiArrowRightDoubleLine, RiArrowRightDoubleLine,
RiArrowRightSLine, RiArrowRightSLine,
RiAttachment2,
RiBankCard2Line, RiBankCard2Line,
RiChat1Line, RiChat1Line,
RiCheckboxBlankCircleLine, RiCheckboxBlankCircleLine,
@@ -13,9 +14,9 @@ import {
RiCheckLine, RiCheckLine,
RiDeleteBin5Line, RiDeleteBin5Line,
RiFileCopyLine, RiFileCopyLine,
RiFileExcel2Line,
RiFileList2Line, RiFileList2Line,
RiFlashlightFill, RiFlashlightFill,
RiFileExcel2Line,
RiGroupLine, RiGroupLine,
RiHistoryLine, RiHistoryLine,
RiMoreFill, RiMoreFill,
@@ -115,6 +116,14 @@ type BuildColumnsArgs = {
showActions: boolean; showActions: boolean;
}; };
function getPaymentMethodTableLabel(method: string) {
if (method === "Transferência bancária") {
return "Transf. bancária";
}
return method;
}
const buildColumns = ({ const buildColumns = ({
currentUserId, currentUserId,
noteAsColumn, noteAsColumn,
@@ -182,6 +191,7 @@ const buildColumns = ({
note, note,
isDivided, isDivided,
isAnticipated, isAnticipated,
hasAttachments,
} = row.original; } = row.original;
const installmentBadge = const installmentBadge =
@@ -191,7 +201,7 @@ const buildColumns = ({
const isBoleto = paymentMethod === "Boleto" && dueDate; const isBoleto = paymentMethod === "Boleto" && dueDate;
const dueDateLabel = const dueDateLabel =
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null; isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
const hasNote = Boolean(note?.trim().length); const hasNote = Boolean(note?.trim().length);
const isLastInstallment = const isLastInstallment =
currentInstallment === installmentCount && currentInstallment === installmentCount &&
@@ -201,13 +211,18 @@ const buildColumns = ({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} /> <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)} {formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span> </span>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <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} {name}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -259,12 +274,6 @@ const buildColumns = ({
</Badge> </Badge>
) : null} ) : null}
{dueDateLabel ? (
<Badge variant="outline" className="px-2 text-xs">
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && ( {isAnticipated && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -301,6 +310,21 @@ const buildColumns = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : 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> </span>
); );
}, },
@@ -366,7 +390,7 @@ const buildColumns = ({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{icon} {icon}
<span>{method}</span> <span>{getPaymentMethodTableLabel(method)}</span>
</span> </span>
); );
}, },

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ export default function MonthNavigation() {
}; };
return ( 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"> <div className="flex items-center gap-1">
<NavigationButton <NavigationButton
direction="left" direction="left"

View File

@@ -1,9 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import type { DashboardNotificationsSnapshot } from "@/features/dashboard/notifications-queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo"; import { Logo } from "@/shared/components/logo";
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell"; import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
import { RefreshPageButton } from "@/shared/components/refresh-page-button"; import { RefreshPageButton } from "@/shared/components/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
import { NavMenu } from "./nav-menu"; import { NavMenu } from "./nav-menu";
import { NavbarUser } from "./navbar-user"; import { NavbarUser } from "./navbar-user";
@@ -40,7 +40,8 @@ export function AppNavbar({
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<NotificationBell <NotificationBell
notifications={notificationsSnapshot.notifications} notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount} unreadCount={notificationsSnapshot.unreadCount}
visibleCount={notificationsSnapshot.visibleCount}
budgetNotifications={notificationsSnapshot.budgetNotifications} budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount} preLancamentosCount={preLancamentosCount}
/> />

View File

@@ -63,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <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" aria-label="Menu do usuário"
> >
<Image <Image

View File

@@ -1,347 +1,81 @@
"use client"; "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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"; } from "@/shared/components/ui/dropdown-menu";
import { import { NotificationBellContent } from "./notification-bell/notification-bell-content";
Empty, import { NotificationBellEmptyState } from "./notification-bell/notification-bell-empty-state";
EmptyDescription, import { NotificationBellHeader } from "./notification-bell/notification-bell-header";
EmptyMedia, import { NotificationBellTrigger } from "./notification-bell/notification-bell-trigger";
EmptyTitle, import type { NotificationBellProps } from "./notification-bell/types";
} from "@/shared/components/ui/empty"; import { useNotificationBell } from "./notification-bell/use-notification-bell";
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";
type NotificationBellProps = { export function NotificationBell(props: NotificationBellProps) {
notifications: DashboardNotification[]; const {
totalCount: number; open,
budgetNotifications: BudgetNotification[]; setOpen,
preLancamentosCount?: number; viewMode,
}; setViewMode,
displayCount,
function formatDate(dateString: string): string { hasUnreadNotifications,
return ( hasAnySourceItems,
formatDateOnly(dateString, { headerCountLabel,
day: "2-digit", hasDashboardNotificationItems,
month: "short", hasArchivedItems,
}) ?? dateString archivedDashboardCount,
); hasVisibleItems,
} displayedPreLancamentosCount,
displayedBudgetNotifications,
function SectionLabel({ invoiceNotifications,
icon, boletoNotifications,
title, handleInboxNavigate,
}: { handleNotificationNavigate,
icon: React.ReactNode; handleToggleRead,
title: string; handleToggleArchive,
}) { showArchived,
return ( } = useNotificationBell(props);
<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");
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip> <NotificationBellTrigger
<TooltipTrigger asChild> open={open}
<DropdownMenuTrigger asChild> hasAnySourceItems={hasAnySourceItems}
<button hasUnreadNotifications={hasUnreadNotifications}
type="button" displayCount={displayCount}
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>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={12} 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 */} <NotificationBellHeader
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-[12px] font-semibold"> hasAnySourceItems={hasAnySourceItems}
<span>Notificações</span> headerCountLabel={headerCountLabel}
{hasNotifications && ( hasDashboardNotificationItems={hasDashboardNotificationItems}
<Badge variant="outline" className="text-[10px] font-semibold"> viewMode={viewMode}
{effectiveTotalCount}{" "} hasArchivedItems={hasArchivedItems}
{effectiveTotalCount === 1 ? "item" : "itens"} archivedDashboardCount={archivedDashboardCount}
</Badge> onViewModeChange={setViewMode}
)} />
</DropdownMenuLabel>
{!hasNotifications ? ( {hasVisibleItems ? (
<div className="px-4 py-8"> <NotificationBellContent
<Empty> displayedPreLancamentosCount={displayedPreLancamentosCount}
<EmptyMedia> displayedBudgetNotifications={displayedBudgetNotifications}
<RiCheckboxCircleFill color="green" /> invoiceNotifications={invoiceNotifications}
</EmptyMedia> boletoNotifications={boletoNotifications}
<EmptyTitle>Nenhuma notificação</EmptyTitle> onInboxNavigate={handleInboxNavigate}
<EmptyDescription> onNotificationNavigate={handleNotificationNavigate}
Você está em dia com seus pagamentos! onToggleRead={handleToggleRead}
</EmptyDescription> onToggleArchive={handleToggleArchive}
</Empty> />
</div>
) : ( ) : (
<div className="max-h-[460px] overflow-y-auto pb-2"> <NotificationBellEmptyState
{/* Pré-lançamentos */} showArchived={showArchived}
{preLancamentosCount > 0 && ( hasArchivedItems={hasArchivedItems}
<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>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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

@@ -56,7 +56,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

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

View File

@@ -149,7 +149,7 @@ export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
{ label: "Chave", value: "RiKeyLine" }, { label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" }, { label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" }, { label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" }, { label: "Anexo", value: "RiAttachment2" },
{ label: "Download", value: "RiDownloadLine" }, { label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" }, { label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" }, { label: "Nuvem Download", value: "RiCloudDownloadLine" },
@@ -327,7 +327,7 @@ export const CATEGORY_ICON_GROUPS: CategoryIconGroup[] = [
{ label: "Chave", value: "RiKeyLine" }, { label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" }, { label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" }, { label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" }, { label: "Anexo", value: "RiAttachment2" },
{ label: "Download", value: "RiDownloadLine" }, { label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" }, { label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" }, { 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 // Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
function getField(block: string, tag: string): string | null { function getField(block: string, tag: string): string | null {

View File

@@ -1,5 +1,8 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { ImportStatement, ImportedTransaction } from "@/shared/lib/import/types"; import type {
ImportedTransaction,
ImportStatement,
} from "@/shared/lib/import/types";
function parseDateValue(value: unknown): string | null { function parseDateValue(value: unknown): string | null {
if (value == null || value === "") return null; if (value == null || value === "") return null;

View File

@@ -0,0 +1,16 @@
/**
* Detecta se um erro indica que a tabela `dashboard_notification_states`
* ainda nao existe no banco (migration pendente).
*/
export function isNotificationStatesTableMissing(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes("dashboard_notification_states") &&
(message.includes("does not exist") || message.includes("relation"))
);
}

View File

@@ -0,0 +1,52 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { S3_BUCKET, s3 } from "./s3-client";
export async function createPresignedPutUrl(
fileKey: string,
mimeType: string,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
ContentType: mimeType,
});
return getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutos
}
export async function createPresignedGetUrl(fileKey: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hora
}
export async function headS3Object(fileKey: string): Promise<{
contentLength: number | null;
contentType: string | null;
}> {
const command = new HeadObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
const result = await s3.send(command);
return {
contentLength: result.ContentLength ?? null,
contentType: result.ContentType ?? null,
};
}
export async function deleteS3Object(fileKey: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
await s3.send(command);
}

View File

@@ -0,0 +1,13 @@
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT ?? "",
region: process.env.S3_REGION ?? "auto",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true,
});
export const S3_BUCKET = process.env.S3_BUCKET ?? "attachments";

View File

@@ -1,3 +1,4 @@
export * from "./actions"; export * from "./actions";
export * from "./calendar"; export * from "./calendar";
export * from "./notifications";
export * from "./reports"; export * from "./reports";

View File

@@ -0,0 +1,39 @@
export type NotificationType = "overdue" | "due_soon";
export type BudgetStatus = "exceeded" | "critical";
export type DashboardNotificationStateFields = {
notificationKey: string;
fingerprint: string;
href: string;
isRead: boolean;
isArchived: boolean;
readAt: Date | null;
archivedAt: Date | null;
};
export type DashboardNotification = {
type: "invoice" | "boleto";
name: string;
dueDate: string;
status: NotificationType;
amount: number;
period?: string;
showAmount: boolean;
cardLogo?: string | null;
} & DashboardNotificationStateFields;
export type BudgetNotification = {
categoryName: string;
budgetAmount: number;
spentAmount: number;
usedPercentage: number;
status: BudgetStatus;
} & DashboardNotificationStateFields;
export type DashboardNotificationsSnapshot = {
notifications: DashboardNotification[];
budgetNotifications: BudgetNotification[];
unreadCount: number;
visibleCount: number;
};

37
src/shared/utils/id.ts Normal file
View File

@@ -0,0 +1,37 @@
const FALLBACK_HEX_RADIX = 16;
function randomHex(byteCount: number) {
const cryptoApi = globalThis.crypto;
if (cryptoApi?.getRandomValues) {
return Array.from(cryptoApi.getRandomValues(new Uint8Array(byteCount)))
.map((byte) => byte.toString(FALLBACK_HEX_RADIX).padStart(2, "0"))
.join("");
}
let hex = "";
for (let index = 0; index < byteCount; index += 1) {
hex += Math.floor(Math.random() * 256)
.toString(FALLBACK_HEX_RADIX)
.padStart(2, "0");
}
return hex;
}
export function createClientSafeId() {
const cryptoApi = globalThis.crypto;
if (cryptoApi?.randomUUID) {
return cryptoApi.randomUUID();
}
return [
randomHex(4),
randomHex(2),
randomHex(2),
randomHex(2),
randomHex(6),
].join("-");
}