mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
15 Commits
50477fb1be
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839d7d0866 | ||
|
|
7cd7d95245 | ||
|
|
9bd762f7a3 | ||
|
|
9b76db4ce9 | ||
|
|
91457b6490 | ||
|
|
a0a71623d7 | ||
|
|
00e624b8bc | ||
|
|
f82043127a | ||
|
|
32da4f906e | ||
|
|
0bd9d0ac47 | ||
|
|
9f45fd1ecd | ||
|
|
f528e75ee1 | ||
|
|
da32b41bbc | ||
|
|
1e0c93fb6c | ||
|
|
5f70421f5a |
@@ -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
59
.github/workflows/release.yml
vendored
Normal 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
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -5,6 +5,57 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
34
README.md
34
README.md
@@ -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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
1
drizzle/0010_lame_psynapse.sql
Normal file
1
drizzle/0010_lame_psynapse.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- placeholder: migration aplicada via db:push, arquivo original não preservado
|
||||
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
37
drizzle/0023_sturdy_wolfpack.sql
Normal 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";
|
||||
2
drizzle/0026_next_blue_blade.sql
Normal file
2
drizzle/0026_next_blue_blade.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||
14
drizzle/0027_glorious_mindworm.sql
Normal file
14
drizzle/0027_glorious_mindworm.sql
Normal 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");
|
||||
2704
drizzle/meta/0023_snapshot.json
Normal file
2704
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,13 @@
|
||||
"when": 1748000000000,
|
||||
"tag": "0022_import-category-mappings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774529878374,
|
||||
"tag": "0023_sturdy_wolfpack",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
package.json
25
package.json
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.0.1",
|
||||
"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.63",
|
||||
"@ai-sdk/google": "^3.0.52",
|
||||
"@ai-sdk/openai": "^3.0.47",
|
||||
"@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.134",
|
||||
"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,7 +89,7 @@
|
||||
"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",
|
||||
@@ -97,6 +100,6 @@
|
||||
"drizzle-kit": "0.31.10",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
1579
pnpm-lock.yaml
generated
1579
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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)"
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
149
src/db/schema.ts
149
src/db/schema.ts
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}`],
|
||||
|
||||
252
src/features/dashboard/notifications-actions.ts
Normal file
252
src/features/dashboard/notifications-actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -75,7 +75,9 @@ function getItemDateKey(date: Date): string {
|
||||
// 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);
|
||||
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function getGroupLabel(dateKey: string): string {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
435
src/features/transactions/actions/attachments.ts
Normal file
435
src/features/transactions/actions/attachments.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -705,6 +705,7 @@ export const updateBulkSchema = z.object({
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
isSettled: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
8
src/features/transactions/attachments-config.ts
Normal file
8
src/features/transactions/attachments-config.ts
Normal 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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface TransactionDialogProps {
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
isSettled: boolean | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightDoubleLine,
|
||||
RiArrowRightSLine,
|
||||
RiAttachment2,
|
||||
RiBankCard2Line,
|
||||
RiChat1Line,
|
||||
RiCheckboxBlankCircleLine,
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
||||
isAnticipated: boolean;
|
||||
anticipationId: string | null;
|
||||
seriesId: string | null;
|
||||
hasAttachments: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
16
src/shared/lib/notifications/is-table-missing.ts
Normal file
16
src/shared/lib/notifications/is-table-missing.ts
Normal 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"))
|
||||
);
|
||||
}
|
||||
52
src/shared/lib/storage/presign.ts
Normal file
52
src/shared/lib/storage/presign.ts
Normal 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);
|
||||
}
|
||||
13
src/shared/lib/storage/s3-client.ts
Normal file
13
src/shared/lib/storage/s3-client.ts
Normal 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";
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./actions";
|
||||
export * from "./calendar";
|
||||
export * from "./notifications";
|
||||
export * from "./reports";
|
||||
|
||||
39
src/shared/lib/types/notifications.ts
Normal file
39
src/shared/lib/types/notifications.ts
Normal 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
37
src/shared/utils/id.ts
Normal 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("-");
|
||||
}
|
||||
Reference in New Issue
Block a user