34 Commits

Author SHA1 Message Date
Felipe Coutinho
20d0c3e0a7 chore(docs): atualizar regra de versionamento no CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:56 +00:00
Felipe Coutinho
71b5a004e3 chore: ajustes de formatação e configuração
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:27 +00:00
Felipe Coutinho
65b1506d75 chore(release): publicar versão 2.1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:23 +00:00
Felipe Coutinho
2a458d5a3c chore(configurações): redesign visual da página de configurações
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:19 +00:00
Felipe Coutinho
f418987f47 feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:33 +00:00
Felipe Coutinho
59b4dea071 feat(preferências): configuração de tamanho máximo de anexo por arquivo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:28 +00:00
Felipe Coutinho
6ce132fe0c feat(db): adicionar coluna attachmentMaxSizeMb em userPreferences
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:45:41 +00:00
Felipe Coutinho
49731238e4 Update version badge from 2.1.0 to 2.1.1 2026-03-29 11:14:23 -03:00
Felipe Coutinho
c5df97f7aa chore(setup): reduzir logo e colocar nome ASCII lado a lado
Logo reduzido de 19 para 10 linhas (seleção dos frames-chave).
Nome do projeto em ASCII art posicionado ao lado direito do logo,
centralizado verticalmente. Tagline abaixo do bloco.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:55:46 +00:00
104 changed files with 11528 additions and 1459 deletions

View File

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

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

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

View File

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

View File

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

View File

@@ -16,8 +16,9 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog. 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
--- ---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

1650
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

@@ -190,6 +190,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={false} allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</section> </section>
</main> </main>

View File

@@ -202,6 +202,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id} defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito" defaultPaymentMethod="Cartão de crédito"
lockCardSelection lockCardSelection

View File

@@ -99,6 +99,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true} allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

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

View File

@@ -390,6 +390,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}

View File

@@ -1,4 +1,4 @@
import { RiArrowRightSLine } from "@remixicon/react"; import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -11,6 +11,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form"; import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
import { fetchSettingsPageData } from "@/features/settings/queries"; import { fetchSettingsPageData } from "@/features/settings/queries";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -64,12 +65,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Preferências</h2> <h2 className="text-xl font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades. configurações de acordo com suas necessidades.
</p> </p>
</div> </div>
<Separator />
<PreferencesForm <PreferencesForm
statementNoteAsColumn={ statementNoteAsColumn={
userPreferences?.statementNoteAsColumn ?? false userPreferences?.statementNoteAsColumn ?? false
@@ -77,25 +79,46 @@ export default async function Page() {
transactionsColumnOrder={ transactionsColumnOrder={
userPreferences?.transactionsColumnOrder ?? null userPreferences?.transactionsColumnOrder ?? null
} }
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</div> </div>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="companion" className="mt-4"> <TabsContent value="companion" className="mt-4">
<CompanionTab tokens={userApiTokens} /> <Card className="p-6">
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-bold">OpenMonetis Companion</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
caixa de entrada.
</p>
</div>
<Separator />
<CompanionTab tokens={userApiTokens} />
</div>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="nome" className="mt-4"> <TabsContent value="nome" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2> <h2 className="text-xl font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações. ser exibido em diferentes seções do app e em comunicações.
</p> </p>
</div> </div>
<Separator />
<UpdateNameForm currentName={userName} /> <UpdateNameForm currentName={userName} />
</div> </div>
</Card> </Card>
@@ -105,12 +128,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2> <h2 className="text-xl font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local Defina uma nova senha para sua conta. Guarde-a em local
seguro. seguro.
</p> </p>
</div> </div>
<Separator />
<UpdatePasswordForm authProvider={authProvider} /> <UpdatePasswordForm authProvider={authProvider} />
</div> </div>
</Card> </Card>
@@ -120,12 +144,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Passkeys</h2> <h2 className="text-xl font-bold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID, Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança. Touch ID, Windows Hello) ou chaves de segurança.
</p> </p>
</div> </div>
<Separator />
<PasskeysForm /> <PasskeysForm />
</div> </div>
</Card> </Card>
@@ -135,13 +160,14 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2> <h2 className="text-xl font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração. atual (quando aplicável) para concluir a alteração.
</p> </p>
</div> </div>
<Separator />
<UpdateEmailForm <UpdateEmailForm
currentEmail={userEmail} currentEmail={userEmail}
authProvider={authProvider} authProvider={authProvider}
@@ -154,14 +180,15 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1 text-destructive"> <h2 className="text-xl font-bold mb-1 text-destructive">
Ações perigosas Ações perigosas
</h2> </h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Você pode zerar os dados do OpenMonetis e manter seu acesso, Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível. ou excluir sua conta inteira de forma irreversível.
</p> </p>
</div> </div>
<Separator />
<DeleteAccountForm /> <DeleteAccountForm />
</div> </div>
</Card> </Card>

View File

@@ -102,6 +102,7 @@ export default async function Page({ searchParams }: PageProps) {
}} }}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image"; import Image from "next/image";
import { memo } from "react";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
@@ -62,7 +63,7 @@ interface InboxCardProps {
onSelectToggle?: (id: string) => void; onSelectToggle?: (id: string) => void;
} }
export function InboxCard({ export const InboxCard = memo(function InboxCard({
item, item,
readonly, readonly,
appLogoMap, appLogoMap,
@@ -222,4 +223,4 @@ export function InboxCard({
)} )}
</Card> </Card>
); );
} });

View File

@@ -13,7 +13,13 @@ import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image"; import Image from "next/image";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
bulkDeleteInboxItemsAction, bulkDeleteInboxItemsAction,
@@ -57,18 +63,27 @@ import type {
SelectOption, SelectOption,
} from "./types"; } from "./types";
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png"; const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function getDateKey(date: Date): string { // O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
const adjusted = new Date(date.getTime() + BRASILIA_OFFSET_MS); // Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
return adjusted.toISOString().slice(0, 10); // Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
.toISOString()
.slice(0, 10);
} }
function getGroupLabel(dateKey: string): string { function getGroupLabel(dateKey: string): string {
const now = new Date(); const now = new Date();
const todayKey = getDateKey(now); const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getDateKey( const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000), new Date(now.getTime() - 24 * 60 * 60 * 1000),
); );
if (dateKey === todayKey) return "Hoje"; if (dateKey === todayKey) return "Hoje";
@@ -84,7 +99,7 @@ function groupItemsByDay(
): { label: string; items: InboxItem[] }[] { ): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>(); const groups = new Map<string, InboxItem[]>();
for (const item of items) { for (const item of items) {
const key = getDateKey(new Date(item.notificationTimestamp)); const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key); const group = groups.get(key);
if (group) { if (group) {
group.push(item); group.push(item);
@@ -234,20 +249,20 @@ export function InboxPage({
} }
}; };
const handleProcessRequest = (item: InboxItem) => { const handleProcessRequest = useCallback((item: InboxItem) => {
setItemToProcess(item); setItemToProcess(item);
setProcessOpen(true); setProcessOpen(true);
}; }, []);
const handleDetailsRequest = (item: InboxItem) => { const handleDetailsRequest = useCallback((item: InboxItem) => {
setItemDetails(item); setItemDetails(item);
setDetailsOpen(true); setDetailsOpen(true);
}; }, []);
const handleDiscardRequest = (item: InboxItem) => { const handleDiscardRequest = useCallback((item: InboxItem) => {
setItemToDiscard(item); setItemToDiscard(item);
setDiscardOpen(true); setDiscardOpen(true);
}; }, []);
const handleDiscardConfirm = async () => { const handleDiscardConfirm = async () => {
if (!itemToDiscard) return; if (!itemToDiscard) return;
@@ -272,10 +287,10 @@ export function InboxPage({
} }
}; };
const handleDeleteRequest = (item: InboxItem) => { const handleDeleteRequest = useCallback((item: InboxItem) => {
setItemToDelete(item); setItemToDelete(item);
setDeleteOpen(true); setDeleteOpen(true);
}; }, []);
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!itemToDelete) return; if (!itemToDelete) return;
@@ -300,10 +315,10 @@ export function InboxPage({
} }
}; };
const handleRestoreRequest = (item: InboxItem) => { const handleRestoreRequest = useCallback((item: InboxItem) => {
setItemToRestore(item); setItemToRestore(item);
setRestoreOpen(true); setRestoreOpen(true);
}; }, []);
const handleRestoreToPendingConfirm = async () => { const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return; if (!itemToRestore) return;
@@ -326,13 +341,13 @@ export function InboxPage({
setSelectedIds((current) => current.filter((id) => visibleIds.has(id))); setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [items]); }, [items]);
const toggleSelection = (id: string) => { const toggleSelection = useCallback((id: string) => {
setSelectedIds((current) => setSelectedIds((current) =>
current.includes(id) current.includes(id)
? current.filter((value) => value !== id) ? current.filter((value) => value !== id)
: [...current, id], : [...current, id],
); );
}; }, []);
const allSelected = items.length > 0 && selectedIds.length === items.length; const allSelected = items.length > 0 && selectedIds.length === items.length;

View File

@@ -89,15 +89,14 @@ export async function fetchInboxSourceApps(
status: InboxStatus, status: InboxStatus,
): Promise<string[]> { ): Promise<string[]> {
const rows = await db const rows = await db
.select({ name: inboxItems.sourceAppName }) .selectDistinct({ name: inboxItems.sourceAppName })
.from(inboxItems) .from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
const seen = new Set<string>(); return rows
for (const row of rows) { .map((row) => row.name)
if (row.name) seen.add(row.name); .filter((name): name is string => name !== null)
} .sort();
return [...seen].sort();
} }
export async function fetchInboxStatusCounts( export async function fetchInboxStatusCounts(

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ const baseSchema = z.object({
.string() .string()
.trim() .trim()
.email("Informe um e-mail válido.") .email("Informe um e-mail válido.")
.optional() .nullish()
.transform((value) => normalizeOptionalString(value)), .transform((value) => normalizeOptionalString(value)),
status: statusEnum, status: statusEnum,
note: noteSchema, note: noteSchema,

View File

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

View File

@@ -65,6 +65,7 @@ const resetAccountSchema = z.object({
const updatePreferencesSchema = z.object({ const updatePreferencesSchema = z.object({
statementNoteAsColumn: z.boolean(), statementNoteAsColumn: z.boolean(),
transactionsColumnOrder: z.array(z.string()).nullable(), transactionsColumnOrder: z.array(z.string()).nullable(),
attachmentMaxSizeMb: z.number().int().min(1).max(100),
}); });
type ResettableUser = { type ResettableUser = {
@@ -561,6 +562,7 @@ export async function updatePreferencesAction(
.set({ .set({
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.userPreferences.userId, session.user.id)); .where(eq(schema.userPreferences.userId, session.user.id));
@@ -570,6 +572,7 @@ export async function updatePreferencesAction(
userId: session.user.id, userId: session.user.id,
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
}); });
} }

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
RiAndroidLine,
RiDownload2Line, RiDownload2Line,
RiExternalLinkLine, RiExternalLinkLine,
RiNotification3Line, RiNotification3Line,
@@ -9,7 +8,6 @@ import {
RiShieldCheckLine, RiShieldCheckLine,
} from "@remixicon/react"; } from "@remixicon/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Card } from "@/shared/components/ui/card";
import { ApiTokensForm } from "./api-tokens-form"; import { ApiTokensForm } from "./api-tokens-form";
interface ApiToken { interface ApiToken {
@@ -69,49 +67,28 @@ const steps: {
export function CompanionTab({ tokens }: CompanionTabProps) { export function CompanionTab({ tokens }: CompanionTabProps) {
return ( return (
<Card className="p-6"> <div className="space-y-6">
<div className="space-y-6"> {/* Steps */}
{/* Header */} <div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<div> {steps.map((step, index) => (
<div className="flex items-center gap-2 mb-1"> <div key={step.title} className="flex items-start gap-2">
<h2 className="text-lg font-bold">OpenMonetis Companion</h2> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10"> <step.icon className="h-4 w-4" />
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco (Nubank,
Itaú, Bradesco, Inter, C6 e outros) e envie para sua caixa de
entrada.
</p>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{steps.map((step, index) => (
<div key={step.title} className="flex items-start gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<step.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
</p>
<p className="text-xs text-muted-foreground">
{step.description}
</p>
</div>
</div> </div>
))} <div className="min-w-0">
</div> <p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
{/* Divider */} </p>
<div className="border-t" /> <p className="text-xs text-muted-foreground">
{step.description}
{/* Devices */} </p>
<ApiTokensForm tokens={tokens} /> </div>
</div>
))}
</div> </div>
</Card>
{/* Devices */}
<ApiTokensForm tokens={tokens} />
</div>
); );
} }

View File

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

View File

@@ -21,17 +21,27 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updatePreferencesAction } from "@/features/settings/actions"; import { updatePreferencesAction } from "@/features/settings/actions";
import {
ATTACHMENT_SIZE_OPTIONS,
type AttachmentSizeOption,
} from "@/features/transactions/attachments-config";
import { import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER, DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS, LANCAMENTOS_COLUMN_LABELS,
} from "@/features/transactions/column-order"; } from "@/features/transactions/column-order";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch"; import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
interface PreferencesFormProps { interface PreferencesFormProps {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
function SortableColumnItem({ id }: { id: string }) { function SortableColumnItem({ id }: { id: string }) {
@@ -74,6 +84,7 @@ function SortableColumnItem({ id }: { id: string }) {
export function PreferencesForm({ export function PreferencesForm({
statementNoteAsColumn: initialExtratoNoteAsColumn, statementNoteAsColumn: initialExtratoNoteAsColumn,
transactionsColumnOrder: initialColumnOrder, transactionsColumnOrder: initialColumnOrder,
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
}: PreferencesFormProps) { }: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -85,6 +96,14 @@ export function PreferencesForm({
? initialColumnOrder ? initialColumnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER, : DEFAULT_LANCAMENTOS_COLUMN_ORDER,
); );
const [attachmentMaxSizeMb, setAttachmentMaxSizeMb] =
useState<AttachmentSizeOption>(
(ATTACHMENT_SIZE_OPTIONS.includes(
initialAttachmentMaxSizeMb as AttachmentSizeOption,
)
? initialAttachmentMaxSizeMb
: 50) as AttachmentSizeOption,
);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -109,6 +128,7 @@ export function PreferencesForm({
const result = await updatePreferencesAction({ const result = await updatePreferencesAction({
statementNoteAsColumn, statementNoteAsColumn,
transactionsColumnOrder: columnOrder, transactionsColumnOrder: columnOrder,
attachmentMaxSizeMb,
}); });
if (result.success) { if (result.success) {
@@ -122,19 +142,18 @@ export function PreferencesForm({
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-8"> <form onSubmit={handleSubmit} className="flex flex-col gap-8">
{/* Seção: Extrato / Lançamentos */} {/* Seção: Lançamentos */}
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3> <h3 className="text-base font-semibold">Lançamentos</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de Configurações de exibição da tabela de movimentações.
movimentações.
</p> </p>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md"> <section className="flex items-center justify-between max-w-md">
<div className="space-y-0.5"> <div className="space-y-2">
<Label htmlFor="extrato-note-column" className="text-base"> <Label htmlFor="extrato-note-column" className="text-sm">
Anotações em coluna Anotações em coluna
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -149,10 +168,12 @@ export function PreferencesForm({
onCheckedChange={setExtratoNoteAsColumn} onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending} disabled={isPending}
/> />
</div> </section>
<div className="space-y-2 max-w-md"> <Separator />
<Label className="text-base">Ordem das colunas</Label>
<section className="space-y-2 max-w-md">
<Label className="text-sm">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na Arraste os itens para definir a ordem em que as colunas aparecem na
tabela do extrato e dos lançamentos. tabela do extrato e dos lançamentos.
@@ -173,7 +194,43 @@ export function PreferencesForm({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </section>
<Separator />
<section className="space-y-2">
<Label className="text-sm">Anexos</Label>
<p className="text-sm text-muted-foreground">
Configurações de upload de arquivos nos lançamentos.
</p>
<div className="space-y-2 max-w-md mt-4">
<Label>Tamanho máximo por arquivo</Label>
<p className="text-sm text-muted-foreground">
Limite aplicado ao upload de PDFs e imagens.
</p>
<ToggleGroup
type="single"
value={String(attachmentMaxSizeMb)}
onValueChange={(val) => {
if (val)
setAttachmentMaxSizeMb(Number(val) as AttachmentSizeOption);
}}
className="flex flex-wrap gap-2 justify-start"
>
{ATTACHMENT_SIZE_OPTIONS.map((size) => (
<ToggleGroupItem
key={size}
value={String(size)}
aria-label={`${size} MB`}
className="min-w-14"
>
{size} MB
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</section>
</section> </section>
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -5,6 +5,7 @@ import { db, schema } from "@/shared/lib/db";
export interface UserPreferences { export interface UserPreferences {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
export interface ApiToken { export interface ApiToken {
@@ -32,6 +33,7 @@ export async function fetchUserPreferences(
.select({ .select({
statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn, statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn,
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder, transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
}) })
.from(schema.userPreferences) .from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId)) .where(eq(schema.userPreferences.userId, userId))

View File

@@ -0,0 +1,554 @@
"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),
scope: z.enum(["current", "period", "future", "all"]).default("current"),
});
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;
scope?: "current" | "period" | "future" | "all";
}): 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,
period: transactions.period,
})
.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.scope !== "current" && transaction.seriesId) {
const seriesRows = await db
.select({ id: transactions.id, period: transactions.period })
.from(transactions)
.where(
and(
eq(transactions.seriesId, transaction.seriesId),
eq(transactions.userId, user.id),
),
);
if (data.scope === "period") {
transactionIds = seriesRows
.filter((r) => r.period === transaction.period)
.map((r) => r.id);
} else if (data.scope === "future") {
transactionIds = seriesRows
.filter((r) => (r.period ?? "") >= (transaction.period ?? ""))
.map((r) => r.id);
} else {
transactionIds = seriesRows.map((r) => r.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),
})),
);
}
const detachBulkSchema = z.object({
attachmentId: z.string().uuid(),
transactionId: z.string().uuid(),
scope: z.enum(["current", "period", "future", "all"]),
});
export async function detachAttachmentBulkAction(input: {
attachmentId: string;
transactionId: string;
scope: "current" | "period" | "future" | "all";
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = detachBulkSchema.parse(input);
const [baseTransaction] = await db
.select({
id: transactions.id,
seriesId: transactions.seriesId,
period: transactions.period,
})
.from(transactions)
.where(
and(
eq(transactions.id, data.transactionId),
eq(transactions.userId, user.id),
),
);
if (!baseTransaction) {
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." };
}
let targetTransactionIds: string[];
if (data.scope === "current" || !baseTransaction.seriesId) {
targetTransactionIds = [data.transactionId];
} else {
const seriesRows = await db
.select({ id: transactions.id, period: transactions.period })
.from(transactions)
.where(
and(
eq(transactions.seriesId, baseTransaction.seriesId),
eq(transactions.userId, user.id),
),
);
if (data.scope === "period") {
targetTransactionIds = seriesRows
.filter((r) => r.period === baseTransaction.period)
.map((r) => r.id);
} else if (data.scope === "future") {
targetTransactionIds = seriesRows
.filter((r) => (r.period ?? "") >= (baseTransaction.period ?? ""))
.map((r) => r.id);
} else {
targetTransactionIds = seriesRows.map((r) => r.id);
}
}
if (targetTransactionIds.length > 0) {
await db
.delete(transactionAttachments)
.where(
and(
inArray(transactionAttachments.transactionId, targetTransactionIds),
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);
}
}
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
export async function cleanupAttachmentsAfterTransactionDelete(
attachmentData: Array<{ id: string; fileKey: string }>,
): Promise<void> {
if (attachmentData.length === 0) return;
const uniqueIds = [...new Set(attachmentData.map((a) => a.id))];
const remaining = await db
.select({
attachmentId: transactionAttachments.attachmentId,
total: count(),
})
.from(transactionAttachments)
.where(inArray(transactionAttachments.attachmentId, uniqueIds))
.groupBy(transactionAttachments.attachmentId);
const remainingMap = new Map(remaining.map((r) => [r.attachmentId, r.total]));
for (const att of attachmentData) {
if (!remainingMap.has(att.id) || (remainingMap.get(att.id) ?? 0) === 0) {
await deleteS3Object(att.fileKey);
await db.delete(attachments).where(eq(attachments.id, att.id));
}
}
}

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { transactions } from "@/db/schema";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
@@ -80,6 +80,24 @@ export async function deleteTransactionBulkAction(
return { success: true, message: "Lançamento removido com sucesso." }; return { success: true, message: "Lançamento removido com sucesso." };
} }
if (data.scope === "period") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period ?? ""),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram removidos.",
};
}
if (data.scope === "future") { if (data.scope === "future") {
await db await db
.delete(transactions) .delete(transactions)
@@ -147,6 +165,7 @@ export async function updateTransactionBulkAction(
condition: true, condition: true,
transactionType: true, transactionType: true,
purchaseDate: true, purchaseDate: true,
payerId: true,
}, },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
@@ -169,9 +188,11 @@ export async function updateTransactionBulkAction(
name: data.name, name: data.name,
categoryId: data.categoryId ?? null, categoryId: data.categoryId ?? null,
note: data.note ?? null, note: data.note ?? null,
payerId: data.payerId ?? null, // "period" atualiza todos os pagadores do mês — preserva o payerId de cada linha
...(data.scope !== "period" && { payerId: data.payerId ?? null }),
accountId: data.accountId ?? null, accountId: data.accountId ?? null,
cardId: data.cardId ?? null, cardId: data.cardId ?? null,
...(data.isSettled !== undefined && { isSettled: data.isSettled }),
}; };
if (data.amount !== undefined) { if (data.amount !== undefined) {
@@ -308,6 +329,42 @@ export async function updateTransactionBulkAction(
return { success: true, message: "Lançamento atualizado com sucesso." }; return { success: true, message: "Lançamento atualizado com sucesso." };
} }
if (data.scope === "period") {
if (!existing.period) {
return {
success: false,
error: "Período do lançamento não encontrado.",
};
}
const periodLancamentos = await db.query.transactions.findMany({
columns: { id: true, purchaseDate: true },
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period),
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram atualizados.",
};
}
const payerIdFilter = existing.payerId
? eq(transactions.payerId, existing.payerId)
: isNull(transactions.payerId);
if (data.scope === "future") { if (data.scope === "future") {
const futureLancamentos = await db.query.transactions.findMany({ const futureLancamentos = await db.query.transactions.findMany({
columns: { columns: {
@@ -318,6 +375,7 @@ export async function updateTransactionBulkAction(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`, sql`${transactions.period} >= ${existing.period}`,
payerIdFilter,
), ),
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });
@@ -345,6 +403,7 @@ export async function updateTransactionBulkAction(
where: and( where: and(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
payerIdFilter,
), ),
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });

View File

@@ -664,7 +664,7 @@ export const buildLancamentoRecords = ({
export const deleteBulkSchema = z.object({ export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
}); });
@@ -673,7 +673,7 @@ export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
export const updateBulkSchema = z.object({ export const updateBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
name: z name: z
@@ -705,6 +705,7 @@ export const updateBulkSchema = z.object({
}) })
.optional() .optional()
.nullable(), .nullable(),
isSettled: z.boolean().nullable().optional(),
}); });
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>; export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;

View File

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

View File

@@ -0,0 +1,13 @@
export const ALLOWED_MIME_TYPES = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];

View File

@@ -0,0 +1,95 @@
"use client";
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
import { useRef } from "react";
import { toast } from "sonner";
import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps {
file: File | null;
onChange: (file: File | null) => void;
maxSizeMb?: number;
}
export function AttachmentFilePicker({
file,
onChange,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
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 > maxFileSizeBytes) {
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
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. {maxSizeMb} MB
</span>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,286 @@
"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;
isPendingDelete?: boolean;
onPendingDelete?: (attachmentId: string) => void;
onUndoPendingDelete?: (attachmentId: string) => void;
}
export function AttachmentItem({
attachmentId,
transactionId,
fileName,
fileSize,
mimeType,
url,
onDeleted,
readonly = false,
isPendingDelete = false,
onPendingDelete,
onUndoPendingDelete,
}: 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() {
if (onPendingDelete) {
onPendingDelete(attachmentId);
setConfirmOpen(false);
return;
}
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 transition-opacity ${isPendingDelete ? "opacity-50 border-dashed" : ""}`}
>
<AttachmentIcon mimeType={mimeType} />
{isPendingDelete ? (
<div className="flex-1 min-w-0">
<p className="truncate font-medium line-through">{fileName}</p>
<p className="text-xs text-muted-foreground">
Será removido ao salvar
</p>
</div>
) : 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>
)}
{!isPendingDelete && (
<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 &&
(isPendingDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onUndoPendingDelete?.(attachmentId)}
>
Desfazer
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
>
<RiDeleteBinLine className="size-4" />
</Button>
))}
</div>
{canPreview && (
<AttachmentPreview
open={previewOpen}
onOpenChange={setPreviewOpen}
fileName={fileName}
mimeType={mimeType}
url={url}
/>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza que deseja remover{" "}
<span className="break-all font-medium text-foreground">
{fileName}
</span>
?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
Cancelar
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? "Removendo..." : "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { RiFileAddLine } from "@remixicon/react";
import { useCallback, useEffect, useState } from "react";
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
import { Button } from "@/shared/components/ui/button";
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;
readonly?: boolean;
onLoaded?: (count: number) => void;
pendingDetachIds?: string[];
onPendingDetach?: (attachmentId: string) => void;
onUndoPendingDetach?: (attachmentId: string) => void;
pendingUploadFiles?: File[];
onPendingUpload?: (file: File) => void;
onCancelPendingUpload?: (file: File) => void;
maxSizeMb?: number;
}
export function AttachmentSection({
transactionId,
readonly = false,
onLoaded,
pendingDetachIds,
onPendingDetach,
onUndoPendingDetach,
pendingUploadFiles,
onPendingUpload,
onCancelPendingUpload,
maxSizeMb,
}: 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]);
if (isLoading) {
return <p className="text-xs text-muted-foreground">Carregando...</p>;
}
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
return (
<div className="min-w-0 space-y-2 overflow-hidden">
{items.length === 0 && !hasPendingUploads && readonly && (
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
)}
{(items.length > 0 || hasPendingUploads) && (
<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}
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
onPendingDelete={onPendingDetach}
onUndoPendingDelete={onUndoPendingDetach}
/>
))}
{pendingUploadFiles?.map((file) => (
<div
key={`${file.name}-${file.size}`}
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-dashed px-3 py-2 text-sm opacity-60"
>
<RiFileAddLine className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
Será adicionado ao salvar
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onCancelPendingUpload?.(file)}
>
Cancelar
</Button>
</div>
))}
</div>
)}
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
onUploaded={load}
onPendingUpload={onPendingUpload}
maxSizeMb={maxSizeMb}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { RiAttachment2 } from "@remixicon/react";
import { useRef, useTransition } from "react";
import { toast } from "sonner";
import {
confirmAttachmentUploadAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/attachments-config";
interface AttachmentUploadProps {
transactionId: string;
onUploaded: () => void;
onPendingUpload?: (file: File) => void;
maxSizeMb?: number;
}
export function AttachmentUpload({
transactionId,
onUploaded,
onPendingUpload,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentUploadProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
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 > maxFileSizeBytes) {
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
return;
}
if (onPendingUpload) {
onPendingUpload(file);
return;
}
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,
});
if (confirmResult.success) {
toast.success(confirmResult.message);
onUploaded();
} else {
toast.error(confirmResult.error);
}
});
}
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. {maxSizeMb} MB
</span>
)}
</button>
</>
);
}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiErrorWarningLine } from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
@@ -13,7 +14,7 @@ import {
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
export type BulkActionScope = "current" | "future" | "all"; export type BulkActionScope = "current" | "period" | "future" | "all";
type BulkActionDialogProps = { type BulkActionDialogProps = {
open: boolean; open: boolean;
@@ -108,6 +109,30 @@ export function BulkActionDialog({
</div> </div>
</div> </div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="period" id="period" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="period"
className="text-sm cursor-pointer font-medium"
>
Todos os pagadores deste período
</Label>
<p className="text-xs text-muted-foreground">
Aplica a todos os lançamentos deste mesmo mês na série
</p>
{scope === "period" && actionType === "edit" && (
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0" />
<p className="text-xs">
Atenção: os valores individuais de cada pagador serão
substituídos pelos valores deste lançamento.
</p>
</div>
)}
</div>
</div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" /> <RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1"> <div className="flex-1">

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export function BoletoFieldsSection({
/> />
</div> </div>
{showPaymentDate ? ( {showPaymentDate ? (
<div className="space-y-2 w-full md:w-1/2"> <div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label> <Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
<DatePicker <DatePicker
id="boletoPaymentDate" id="boletoPaymentDate"

View File

@@ -30,6 +30,8 @@ export interface TransactionDialogProps {
forceShowTransactionType?: boolean; forceShowTransactionType?: boolean;
/** Called after successful create/update. Receives the action result. */ /** Called after successful create/update. Receives the action result. */
onSuccess?: () => void; onSuccess?: () => void;
/** Max attachment file size in MB for this user */
maxSizeMb?: number;
onBulkEditRequest?: (data: { onBulkEditRequest?: (data: {
id: string; id: string;
name: string; name: string;
@@ -41,6 +43,9 @@ export interface TransactionDialogProps {
amount: number; amount: number;
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => void; }) => void;
} }

View File

@@ -6,6 +6,11 @@ import {
createTransactionAction, createTransactionAction,
updateTransactionAction, updateTransactionAction,
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
detachTransactionAttachmentAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { import {
filterSecondaryPayerOptions, filterSecondaryPayerOptions,
groupAndSortCategories, groupAndSortCategories,
@@ -30,7 +35,10 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { useControlledState } from "@/shared/hooks/use-controlled-state"; import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { AttachmentFilePicker } from "../../attachments/attachment-file-picker";
import { AttachmentSection } from "../../attachments/attachment-section";
import { BasicFieldsSection } from "./basic-fields-section"; import { BasicFieldsSection } from "./basic-fields-section";
import { BoletoFieldsSection } from "./boleto-fields-section"; import { BoletoFieldsSection } from "./boleto-fields-section";
import { CategorySection } from "./category-section"; import { CategorySection } from "./category-section";
@@ -65,10 +73,11 @@ export function TransactionDialog({
defaultAmount, defaultAmount,
lockCardSelection, lockCardSelection,
lockPaymentMethod, lockPaymentMethod,
isImporting = false, isImporting,
defaultTransactionType, defaultTransactionType,
forceShowTransactionType = false, forceShowTransactionType,
onSuccess, onSuccess,
maxSizeMb,
onBulkEditRequest, onBulkEditRequest,
}: TransactionDialogProps) { }: TransactionDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
@@ -90,6 +99,9 @@ export function TransactionDialog({
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
@@ -108,8 +120,9 @@ export function TransactionDialog({
}, },
); );
// Derive credit card period on open when cardId is pre-filled // Derive credit card period on open when cardId is pre-filled (create only)
if ( if (
mode !== "update" &&
initial.paymentMethod === "Cartão de crédito" && initial.paymentMethod === "Cartão de crédito" &&
initial.cardId && initial.cardId &&
initial.purchaseDate initial.purchaseDate
@@ -126,6 +139,9 @@ export function TransactionDialog({
setFormState(initial); setFormState(initial);
setErrorMessage(null); setErrorMessage(null);
setPendingFile(null);
setPendingDetachIds([]);
setPendingUploadFiles([]);
} }
}, [ }, [
dialogOpen, dialogOpen,
@@ -313,6 +329,29 @@ export function TransactionDialog({
const result = await createTransactionAction(payload); const result = await createTransactionAction(payload);
if (result.success) { if (result.success) {
if (pendingFile && result.data?.ids?.length) {
const firstId = result.data.ids[0];
const isNewSeries =
formState.condition === "Parcelado" ||
formState.condition === "Recorrente";
const presign = await getPresignedUploadUrlAction({
fileName: pendingFile.name,
mimeType: pendingFile.type,
fileSize: pendingFile.size,
transactionId: firstId,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: pendingFile,
headers: { "Content-Type": pendingFile.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: isNewSeries ? "all" : "current",
});
}
}
toast.success(result.message); toast.success(result.message);
onSuccess?.(); onSuccess?.();
setDialogOpen(false); setDialogOpen(false);
@@ -324,11 +363,11 @@ export function TransactionDialog({
return; return;
} }
// Update mode
const hasSeriesId = Boolean(transaction?.seriesId); const hasSeriesId = Boolean(transaction?.seriesId);
if (hasSeriesId && onBulkEditRequest) { if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action // Para lançamentos em série, passa os arquivos para a página confirmar
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
onBulkEditRequest({ onBulkEditRequest({
id: transaction?.id ?? "", id: transaction?.id ?? "",
name: formState.name.trim(), name: formState.name.trim(),
@@ -346,11 +385,17 @@ export function TransactionDialog({
mode === "update" && formState.paymentMethod === "Boleto" mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null ? formState.boletoPaymentDate || null
: null, : null,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
pendingDetachIds,
pendingUploadFiles,
}); });
return; return;
} }
// Atualização normal para lançamentos únicos ou todos os campos // Atualização normal para lançamentos únicos
const updatePayload: UpdateTransactionInput = { const updatePayload: UpdateTransactionInput = {
id: transaction?.id ?? "", id: transaction?.id ?? "",
...payload, ...payload,
@@ -359,6 +404,31 @@ export function TransactionDialog({
const result = await updateTransactionAction(updatePayload); const result = await updateTransactionAction(updatePayload);
if (result.success) { if (result.success) {
for (const attachmentId of pendingDetachIds) {
await detachTransactionAttachmentAction({
attachmentId,
transactionId: transaction?.id ?? "",
});
}
for (const file of pendingUploadFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: transaction?.id ?? "",
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: "current",
});
}
}
toast.success(result.message); toast.success(result.message);
onSuccess?.(); onSuccess?.();
setDialogOpen(false); setDialogOpen(false);
@@ -411,18 +481,18 @@ export function TransactionDialog({
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null} {trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent> <DialogContent className="min-w-0 overflow-x-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
className="flex flex-col gap-0" className="flex min-w-0 flex-col gap-0"
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate noValidate
> >
<div className="space-y-3 -mx-6 max-h-[70vh] overflow-y-auto px-6 pb-1"> <div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
<BasicFieldsSection <BasicFieldsSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
@@ -473,17 +543,64 @@ export function TransactionDialog({
) : null} ) : null}
{isUpdateMode ? ( {isUpdateMode ? (
<NoteSection <>
formState={formState} <NoteSection
onFieldChange={handleFieldChange} formState={formState}
/> onFieldChange={handleFieldChange}
/>
<div className="space-y-2">
<Label className="text-xs font-medium leading-none">
Anexos
</Label>
<AttachmentSection
transactionId={transaction?.id ?? ""}
maxSizeMb={maxSizeMb}
pendingDetachIds={
transaction?.seriesId ? pendingDetachIds : undefined
}
onPendingDetach={
transaction?.seriesId
? (id) => setPendingDetachIds((prev) => [...prev, id])
: undefined
}
onUndoPendingDetach={
transaction?.seriesId
? (id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
: undefined
}
pendingUploadFiles={
transaction?.seriesId ? pendingUploadFiles : undefined
}
onPendingUpload={
transaction?.seriesId
? (file) =>
setPendingUploadFiles((prev) => [...prev, file])
: undefined
}
onCancelPendingUpload={
transaction?.seriesId
? (file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
: undefined
}
/>
</div>
</>
) : ( ) : (
<Collapsible defaultOpen={formState.condition !== "À vista"}> <Collapsible
defaultOpen={formState.condition !== "À vista"}
className="min-w-0"
>
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4"> <CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" /> <RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
Condições e anotações Condições, anotações e anexos
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3"> <CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
<ConditionSection <ConditionSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
@@ -494,6 +611,11 @@ export function TransactionDialog({
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
/> />
<AttachmentFilePicker
file={pendingFile}
onChange={setPendingFile}
maxSizeMb={maxSizeMb}
/>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
)} )}

View File

@@ -10,6 +10,11 @@ import {
toggleTransactionSettlementAction, toggleTransactionSettlementAction,
updateTransactionBulkAction, updateTransactionBulkAction,
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
detachAttachmentBulkAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import type { import type {
TransactionsExportContext, TransactionsExportContext,
@@ -59,6 +64,7 @@ interface TransactionsPageProps {
lockPaymentMethod?: boolean; lockPaymentMethod?: boolean;
pagination?: TransactionsPaginationState; pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext; exportContext?: TransactionsExportContext;
attachmentMaxSizeMb?: number;
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário) // Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
importPayerOptions?: SelectOption[]; importPayerOptions?: SelectOption[];
importSplitPayerOptions?: SelectOption[]; importSplitPayerOptions?: SelectOption[];
@@ -91,6 +97,7 @@ export function TransactionsPage({
lockPaymentMethod, lockPaymentMethod,
pagination, pagination,
exportContext, exportContext,
attachmentMaxSizeMb,
importPayerOptions, importPayerOptions,
importSplitPayerOptions, importSplitPayerOptions,
importDefaultPayerId, importDefaultPayerId,
@@ -129,6 +136,9 @@ export function TransactionsPage({
amount: number; amount: number;
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
transaction: TransactionItem; transaction: TransactionItem;
} | null>(null); } | null>(null);
const [pendingDeleteData, setPendingDeleteData] = const [pendingDeleteData, setPendingDeleteData] =
@@ -182,7 +192,7 @@ export function TransactionsPage({
toast.success( toast.success(
nextValue nextValue
? `"${item.name}" marcado como pago` ? `"${item.name}" marcado como pago`
: `"${item.name}" desmarcado`, : `"${item.name}" marcado como não pago`,
); );
} catch (error) { } catch (error) {
const message = const message =
@@ -244,6 +254,9 @@ export function TransactionsPage({
amount: number; amount: number;
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => { }) => {
if (!selectedTransaction) { if (!selectedTransaction) {
return; return;
@@ -274,6 +287,7 @@ export function TransactionsPage({
amount: pendingEditData.amount, amount: pendingEditData.amount,
dueDate: pendingEditData.dueDate, dueDate: pendingEditData.dueDate,
boletoPaymentDate: pendingEditData.boletoPaymentDate, boletoPaymentDate: pendingEditData.boletoPaymentDate,
isSettled: pendingEditData.isSettled ?? undefined,
}); });
if (!result.success) { if (!result.success) {
@@ -281,6 +295,36 @@ export function TransactionsPage({
throw new Error(result.error); throw new Error(result.error);
} }
// Propaga remoções de anexo pendentes com o mesmo escopo
for (const attachmentId of pendingEditData.pendingDetachIds) {
await detachAttachmentBulkAction({
attachmentId,
transactionId: pendingEditData.id,
scope,
});
}
// Faz upload dos arquivos pendentes e confirma com o escopo escolhido
for (const file of pendingEditData.pendingUploadFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: pendingEditData.id,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope,
});
}
}
toast.success(result.message); toast.success(result.message);
setBulkEditOpen(false); setBulkEditOpen(false);
setPendingEditData(null); setPendingEditData(null);
@@ -435,6 +479,7 @@ export function TransactionsPage({
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod} lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined} defaultTransactionType={transactionTypeForCreate ?? undefined}
maxSizeMb={attachmentMaxSizeMb}
/> />
) : null} ) : null}
@@ -456,6 +501,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined} transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDialog <TransactionDialog
@@ -477,6 +523,7 @@ export function TransactionsPage({
transaction={transactionToImport ?? undefined} transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
isImporting={true} isImporting={true}
maxSizeMb={attachmentMaxSizeMb}
/> />
<BulkImportDialog <BulkImportDialog
@@ -504,6 +551,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDetailsDialog <TransactionDetailsDialog
@@ -572,7 +620,7 @@ export function TransactionsPage({
onConfirm={handleBulkEdit} onConfirm={handleBulkEdit}
/> />
{allowCreate ? ( {allowCreate && massAddOpen ? (
<MassAddDialog <MassAddDialog
open={massAddOpen} open={massAddOpen}
onOpenChange={setMassAddOpen} onOpenChange={setMassAddOpen}

View File

@@ -6,6 +6,7 @@ import {
RiArrowLeftSLine, RiArrowLeftSLine,
RiArrowRightDoubleLine, RiArrowRightDoubleLine,
RiArrowRightSLine, RiArrowRightSLine,
RiAttachment2,
RiBankCard2Line, RiBankCard2Line,
RiChat1Line, RiChat1Line,
RiCheckboxBlankCircleLine, RiCheckboxBlankCircleLine,
@@ -115,6 +116,14 @@ type BuildColumnsArgs = {
showActions: boolean; showActions: boolean;
}; };
function getPaymentMethodTableLabel(method: string) {
if (method === "Transferência bancária") {
return "Transf. bancária";
}
return method;
}
const buildColumns = ({ const buildColumns = ({
currentUserId, currentUserId,
noteAsColumn, noteAsColumn,
@@ -182,6 +191,7 @@ const buildColumns = ({
note, note,
isDivided, isDivided,
isAnticipated, isAnticipated,
hasAttachments,
} = row.original; } = row.original;
const installmentBadge = const installmentBadge =
@@ -191,7 +201,7 @@ const buildColumns = ({
const isBoleto = paymentMethod === "Boleto" && dueDate; const isBoleto = paymentMethod === "Boleto" && dueDate;
const dueDateLabel = const dueDateLabel =
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null; isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
const hasNote = Boolean(note?.trim().length); const hasNote = Boolean(note?.trim().length);
const isLastInstallment = const isLastInstallment =
currentInstallment === installmentCount && currentInstallment === installmentCount &&
@@ -201,106 +211,126 @@ const buildColumns = ({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} /> <EstablishmentLogo name={name} size={28} />
<span className="flex flex-col">
<span className="text-[11px] text-muted-foreground"> <span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)} {formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span> </span>
<Tooltip> <span className="flex items-center gap-1">
<TooltipTrigger asChild> <Tooltip>
<span className="line-clamp-2 max-w-[160px] font-semibold truncate"> <TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{name} {name}
</span> </TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent side="top" className="max-w-xs">
{name} {isDivided && (
</TooltipContent> <Tooltip>
</Tooltip> <TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">
Dividido entre pagadores
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Parcela antecipada
</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</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>
</span> </span>
{isDivided && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Dividido entre pagadores</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{dueDateLabel ? (
<Badge variant="outline" className="px-2 text-xs">
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Parcela antecipada</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : null}
</span> </span>
); );
}, },
@@ -366,7 +396,7 @@ const buildColumns = ({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{icon} {icon}
<span>{method}</span> <span>{getPaymentMethodTableLabel(method)}</span>
</span> </span>
); );
}, },

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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