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
DB_PORT=5432
# === S3 Server (Opcional) ===
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
# === Email (Opcional) ===
# Provider: Resend (https://resend.com)
RESEND_API_KEY=

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

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

View File

@@ -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/),
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
### Adicionado

View File

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

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.0.0-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.1.0-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -32,6 +32,7 @@
- [Início Rápido (manual)](#-início-rápido)
- [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker)
- [Storage S3 Compatível](#-storage-s3-compatível)
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
- [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo)
@@ -238,6 +239,30 @@ DB_PORT=5433 # Padrão: 5432
---
## ☁️ Storage S3 Compatível
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
### Variáveis
```env
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
```
### Compatibilidade
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
---
## 🔐 Variáveis de Ambiente
Copie `.env.example` para `.env` e configure:
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
POSTGRES_PASSWORD=openmonetis_dev_password
POSTGRES_DB=openmonetis_db
# S3 Server (opcional, necessario para anexos)
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
# Multi-domínio (landing-only no domínio público)
# PUBLIC_DOMAIN=openmonetis.com

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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)
pg_restore --data-only --schema=public --no-owner --no-privileges \
"$DUMP_FILE" | gzip > "$DATA_FILE"
-f - "$DUMP_FILE" | gzip > "$DATA_FILE"
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
RiDatabase2Line,
RiDeviceLine,
RiDownloadCloudLine,
RiErrorWarningLine,
RiEyeOffLine,
RiFileTextLine,
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.",
colorVar: "var(--data-4)",
},
{
icon: RiShieldCheckLine,
title: "Não é para qualquer um",
description:
"Não é uma empresa, não é um SaaS, não é uma plataforma. É um projeto pessoal.",
colorVar: "var(--data-1)",
},
{
icon: RiErrorWarningLine,
title: "Não sou responsável por nada",
description:
"Não sou responsável por nada que aconteça com você ou com seus dados.",
colorVar: "var(--data-9)",
},
];
export function getMetricsItems(stars: number, forks: number) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
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 {
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 "./calendar";
export * from "./notifications";
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("-");
}