Compare commits

...

30 Commits

Author SHA1 Message Date
Felipe Coutinho
5dcd30010e fix(lancamentos): corrige tipo do filtro selecionado 2026-05-23 13:22:32 -03:00
Felipe Coutinho
d589df6993 chore(release): prepara versao 2.6.4 2026-05-23 13:18:15 -03:00
Felipe Coutinho
8a19f0f311 feat(lancamentos): aprimora antecipacao de parcelas 2026-05-23 13:17:55 -03:00
Felipe Coutinho
887885cd98 feat(relatorios): refina analise de parcelas 2026-05-23 13:17:49 -03:00
Felipe Coutinho
7a0e33efd8 feat(lancamentos): adiciona filtro por intervalo de datas 2026-05-23 13:17:42 -03:00
Felipe Coutinho
b9557961e5 style(logos): formata dicionario de nomes 2026-05-23 13:03:27 -03:00
Felipe Coutinho
53c8e47981 Merge branch 'pr-69' 2026-05-23 12:57:48 -03:00
Felipe Coutinho
adc9292cd8 Merge branch 'pr-72' 2026-05-23 12:57:47 -03:00
Felipe Coutinho
b95d6f6752 Merge branch 'pr-70' 2026-05-23 12:57:43 -03:00
lucas
c9f667a065 fix(transactions): restaura scroll no container de anexos do TransactionDialog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:08:30 -03:00
lucas
01d9c6ea05 (HEAD) fix(transactions): remove overflow styles do container de rolagem do TransactionDialog 2026-05-23 11:58:53 -03:00
lucas
d383d2db91 fix(transactions): usa data da última transação ao adicionar nova linha no MassAddDialog
Quando o usuário adiciona uma nova linha de transação no dialog de múltiplos lançamentos,
a data agora é pré-preenchida com o valor da transação anterior em vez da data atual.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:27:43 -03:00
lucas
7a8d01debe feat(logos): adiciona nomes de exibicao via dicionario e busca sem acentos
- Adiciona arquivo display-names.ts com 433 nomes legiveis (ex: bb.png → "Banco do Brasil")
- Adiciona getLogoDisplayName() que consulta dicionario primeiro, com fallback para deriveNameFromLogo
- Adiciona normalizeForSearch() para busca accent-insensitive
- Atualiza account-dialog, card-dialog, logo-picker e use-logo-selection para usar a nova API

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:35:11 -03:00
lucas
3be15d3b15 feat(transactions): adiciona dialogo de confirmacao ao descartar lancamentos massivos
Quando o usuario tenta fechar ou cancelar o dialogo de multiplos lancamentos
com dados ainda nao salvos, agora exibe um ConfirmActionDialog pedindo
confirmacao. Evita perda acidental de dados preenchidos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:30:48 -03:00
Felipe Coutinho
fea9cf81d8 fix(docker): aplica configuracao do pnpm no build 2026-05-21 14:15:24 +00:00
Felipe Coutinho
7a10d431ab chore(release): prepara versao 2.6.1 2026-05-21 14:06:08 +00:00
Felipe Coutinho
b7343eb235 fix(ci): usa pnpm do packageManager no workflow 2026-05-21 13:59:14 +00:00
Felipe Coutinho
3bcc392f38 chore(release): prepara versao 2.6.0 2026-05-21 13:48:19 +00:00
Felipe Coutinho
5241de44af chore(deps): atualiza dependencias e pnpm 2026-05-21 13:48:07 +00:00
Felipe Coutinho
1a75662120 style(ui): refina indicadores e componentes visuais 2026-05-21 13:47:53 +00:00
Felipe Coutinho
7ca3f92467 feat(logos): adiciona logo da Bipa 2026-05-21 13:47:40 +00:00
Felipe Coutinho
6b044f3bc5 feat(cartoes): exibe fatura atual e ajusta indicadores 2026-05-21 13:47:30 +00:00
Felipe Coutinho
4e8f9cc5fa feat(lancamentos): aprimora parcelamentos e protecoes 2026-05-21 13:47:14 +00:00
Felipe Coutinho
b6659ef66e feat(importacao): melhora revisao de extratos 2026-05-21 13:46:42 +00:00
Felipe Coutinho
21d7396c80 feat(auth): permite bloquear novos cadastros 2026-05-21 13:46:26 +00:00
Felipe Coutinho
3a768bc8ba chore: bump para v2.5.7 e polimento geral 2026-05-14 19:13:33 +00:00
Felipe Coutinho
8a03a50132 feat(notes): edição inline de tarefas no modal de anotações 2026-05-14 19:13:29 +00:00
Felipe Coutinho
246bb14a00 feat(transactions): suporte a Ctrl+V em anexos e melhorias de UX no modal 2026-05-14 19:13:26 +00:00
Felipe Coutinho
86bcffec66 feat(reports): polimento visual e prefetch de logos na análise de parcelas 2026-05-14 19:13:09 +00:00
Felipe Coutinho
81e7151876 fix: corrige props depreciadas do react-day-picker v10
`table` renomeado para `month_grid` e `fromYear`/`toYear` substituídos
por `startMonth`/`endMonth`, quebrando o build do Docker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:24:35 +00:00
81 changed files with 3792 additions and 3701 deletions

View File

@@ -17,6 +17,8 @@ POSTGRES_DB=openmonetis_db
# Gere com: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-key-here-change-this
BETTER_AUTH_URL=http://localhost:3000
# Defina como true para bloquear novos cadastros
DISABLE_SIGNUP=false
# === Portas ===
APP_PORT=3000
@@ -59,4 +61,4 @@ OPENROUTER_API_KEY=
# === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=
LOGO_DEV_SECRET_KEY=

View File

@@ -24,8 +24,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4

32
.vscode/settings.json vendored
View File

@@ -1,32 +0,0 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/node_modules": true,
"node_modules": true,
"**/.vscode": true,
".vscode": true,
"**/.next": true,
".next": true
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "never",
"source.organizeImports.biome": "always",
"source.fixAll": "never",
"source.fixAll.biome": "always",
"source.fixAll.eslint": "never"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"prettier.enable": false,
"editor.fontSize": 15,
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

View File

@@ -5,6 +5,98 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.6.4] - 2026-05-23
Esta versão reúne o polimento final antes da próxima publicação: melhora o fluxo de antecipação de parcelas, deixa os dialogs de lançamentos mais seguros e consistentes, e incorpora as contribuições vindas dos PRs abertos para nomes de logos e ajustes no cadastro de transações.
### Adicionado
- Logos: adicionado um dicionário de nomes de exibição para logos, com busca normalizada sem acentos e fallback para o comportamento anterior quando não houver mapeamento específico (PR #69).
- Lançamentos: o dialog de adicionar múltiplos lançamentos agora pede confirmação antes de descartar alterações não salvas ao fechar ou cancelar (PR #70).
### Alterado
- Lançamentos: o modal "Histórico de Antecipações" agora segue o padrão do modal de detalhes, com `Fechar` e `Desfazer Antecipação` no rodapé, contagem dentro do conteúdo e cards de antecipação reorganizados em blocos mais escaneáveis.
- Lançamentos: a antecipação de parcelas agora só permite selecionar parcelas futuras ao período escolhido, evitando antecipar a parcela do próprio mês sem bloquear parcelas seguintes da mesma compra.
- Lançamentos: ao criar uma antecipação, o cache do histórico da série agora é invalidado e o modal refaz a busca ao abrir.
- Lançamentos: ao adicionar uma nova linha no dialog de múltiplos lançamentos, a data passa a seguir a última transação informada em vez de voltar para a data atual (PR #72).
### Corrigido
- Lançamentos: ajustado o espaçamento horizontal da área rolável do dialog de adicionar transação para preservar o alinhamento dos campos e botões (PR #71).
## [2.6.3] - 2026-05-22
Esta versão concentra os ajustes feitos depois da `2.6.2` em um único ciclo público. O foco está em dar mais precisão aos filtros de lançamentos por período real de compra e em polir a análise de parcelas para priorizar parcelamentos mais próximos da quitação sem causar saltos visuais nos cards.
### Adicionado
- Lançamentos: o drawer de filtros agora permite informar data inicial e data final para filtrar a tabela por `data_compra`.
### Alterado
- Lançamentos: quando um intervalo de datas está ativo, a consulta server-side deixa de limitar os dados a um único mês e usa o intervalo real de compra, mantendo paginação e exportação alinhadas ao que aparece na tabela.
- Relatórios: os cards de `/reports/installment-analysis` agora são ordenados pelo percentual pago em ordem decrescente, mantendo a data da compra como critério de desempate.
- Relatórios: em `/reports/installment-analysis`, o contador de parcelas selecionadas agora aparece discretamente no botão "detalhes", sem criar uma área extra no corpo do card.
### Corrigido
- Relatórios: selecionar parcelas em um card de `/reports/installment-analysis` não força mais os outros cards da mesma linha a reservarem espaço vazio para o resumo de seleção.
## [2.6.2] - 2026-05-21
Esta versão corrige o build da imagem Docker depois da atualização para `pnpm@11.1.3`. A etapa de dependências dentro do Docker não recebia a configuração do workspace, então o install congelado falhava ao comparar os `overrides` e as políticas de build com o lockfile.
### Corrigido
- Docker: o `Dockerfile` agora usa `pnpm@11.1.3` em todos os estágios e copia `pnpm-workspace.yaml` antes do `pnpm install --frozen-lockfile`, garantindo que `overrides` e `allowBuilds` sejam aplicados também no build da imagem.
## [2.6.1] - 2026-05-21
Esta versão corrige o pipeline de publicação após o salto para a `2.6.0`. O build do GitHub Actions falhava antes mesmo de instalar as dependências porque o workflow ainda fixava uma versão antiga do `pnpm`, enquanto o projeto já declarava `pnpm@11.1.3` no `packageManager`.
### Corrigido
- CI: o workflow de build deixou de fixar uma versão diferente do `pnpm`, usando a versão declarada em `packageManager` para evitar conflito no `pnpm/action-setup`.
- CI: a política de builds do `pnpm` foi migrada para `allowBuilds`, permitindo explicitamente os scripts necessários de `core-js`, `esbuild`, `sharp` e `unrs-resolver` durante o install no GitHub Actions.
## [2.6.0] - 2026-05-21
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.
### Adicionado
- Autenticação: nova variável `DISABLE_SIGNUP=true` para bloquear novos cadastros. Quando ativa, a tela de cadastro deixa de aparecer na navegação, `/signup` redireciona para login/dashboard e a API de signup responde `403`.
- Lançamentos: compras parceladas agora podem começar em uma parcela intermediária, como `5 de 10`. O sistema gera apenas as parcelas restantes e preserva o cálculo do valor unitário com base no total original.
- Logos: adicionado o logo da Bipa à biblioteca local de marcas.
- Relatórios: a análise de parcelas agora separa parcelas acompanhadas daquelas que ficaram fora do acompanhamento quando o parcelamento começa no meio da série.
### Alterado
- Contas: a página de extrato em `/accounts/[accountId]` voltou a exibir os botões "Nova Receita" e "Nova Despesa", alinhando o fluxo com as demais telas de lançamentos.
- Cartões: os cards de `/cards` agora mostram o valor da fatura do mês atual junto dos indicadores de limite. O limite utilizado passa a considerar faturas em aberto, não apenas o status interno do lançamento.
- Lançamentos: ao criar um lançamento a partir do extrato de uma conta, o diálogo já abre com essa conta selecionada como destino padrão.
- Importação: os controles globais da revisão de extrato foram realinhados à esquerda, com espaçamento mais compacto e larguras mais consistentes.
### Corrigido
- Dashboard: o widget "Status de Pagamento" voltou a mostrar corretamente os valores em "A Pagar", somando despesas pelo valor absoluto e mantendo reembolsos como abatimento.
- Importação: datas vindas de planilhas agora preservam o dia informado no Excel, evitando que `20/05/2026` apareça como `19/05/2026` em fusos como `America/Sao_Paulo`.
- Importação: o seletor de categoria por linha agora mostra apenas categorias compatíveis com o tipo detectado do lançamento, separando receitas e despesas durante a revisão do extrato.
- Importação: cada linha da revisão de extrato agora permite escolher uma pessoa específica, enquanto o campo global continua servindo como atalho para aplicar a pessoa nos lançamentos selecionados.
- Lançamentos: despesas comuns na categoria `Pagamentos` voltaram a poder ser editadas, removidas, copiadas e importadas. A proteção continua valendo apenas para pagamentos automáticos de fatura com nota técnica `AUTO_FATURA:`.
### Dependências
- Stack core: `pnpm` 10.33.0 → 11.1.3.
- Auth: `better-auth` e `@better-auth/passkey` 1.6.10 → 1.6.11.
- AI SDKs: `@ai-sdk/anthropic` 3.0.76 → 3.0.78, `@ai-sdk/google` 3.0.71 → 3.0.75, `@ai-sdk/openai` 3.0.63 → 3.0.64 e `ai` 6.0.177 → 6.0.185.
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1045.0 → 3.1050.0.
- UI e dados: `@tanstack/react-query` 5.100.9 → 5.100.11, `date-fns` 4.1.0 → 4.2.1, `jspdf-autotable` 5.0.7 → 5.0.8, `pg` 8.20.0 → 8.21.0 e `react-day-picker` 10.0.0 → 10.0.1.
- Dev tooling: `@types/node` 25.6.2 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `knip` 6.12.2 → 6.14.1, `tsx` 4.21.0 → 4.22.3 e novo `babel-plugin-react-compiler` 1.0.0.
## [2.5.7] - 2026-05-14
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
### Alterado
- Relatórios: em `/reports/installment-analysis`, os cards de parcelas passam a usar o logo do estabelecimento como avatar principal; o logo do cartão agora aparece menor ao lado do nome do cartão, tanto no card quanto no modal de detalhes.
- Relatórios: a página de análise de parcelas pré-carrega os mapeamentos de logos de estabelecimentos para evitar troca visual após o primeiro render.
- Lançamentos: o campo de anexos no modal agora aceita arquivos colados com `Ctrl+V`, mantendo o botão para buscar arquivos normalmente.
- Lançamentos: o modal agora usa uma única área interna de rolagem, com cabeçalho e rodapé estáveis, reduzindo travadas ao rolar e ao abrir "Condições, anotações e anexos".
- Anotações: tarefas agora podem ser editadas inline no modal "Atualizar anotação"; clicar no texto abre o input e o botão de remover vira botão de salvar naquela linha.
### Corrigido
- Relatórios: o join com cartões na análise de parcelas agora também valida `cards.userId`, mantendo o filtro de ownership explícito na consulta.
## [2.5.6] - 2026-05-07
Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.

View File

@@ -5,12 +5,13 @@
# ============================================
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
# Copiar apenas arquivos de dependências para aproveitar cache
COPY package.json pnpm-lock.yaml* ./
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# Criar pasta public para o postinstall do pdfjs-dist
RUN mkdir -p public
@@ -23,7 +24,8 @@ RUN pnpm install --frozen-lockfile
# ============================================
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
@@ -52,7 +54,8 @@ RUN pnpm build
# ============================================
FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.5.6-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.6.4-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -39,6 +39,7 @@
- [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo)
- [Apoie o Projeto](#-apoie-o-projeto)
- [Star History](#-star-history)
- [Licença](#-licença)
---
@@ -61,7 +62,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis com intervalo de datas, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel.
@@ -127,10 +128,11 @@ Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar
# 1. Baixe o compose
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
# 2. Crie um .en na mesma pasta.
# 2. Crie um .env na mesma pasta.
# .env mínimo recomendado para produção
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
BETTER_AUTH_URL=http://seu-dominio.com
DISABLE_SIGNUP=false # opcional: true bloqueia novos cadastros
# 3. Suba tudo
docker compose up -d
@@ -443,6 +445,9 @@ POSTGRES_USER=openmonetis
POSTGRES_PASSWORD=openmonetis_dev_password
POSTGRES_DB=openmonetis_db
# Autenticação
DISABLE_SIGNUP=false # true bloqueia novos cadastros
# S3 Server (opcional, necessario para anexos)
S3_ENDPOINT=
S3_REGION=
@@ -575,6 +580,18 @@ Outras formas de contribuir: ⭐ estrela no repo, reportar bugs, melhorar docs,
---
## ⭐ Star History
<a href="https://www.star-history.com/?repos=felipegcoutinho%2Fopenmonetis&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
</picture>
</a>
---
## 📄 Licença
**Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).

View File

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

View File

@@ -1,8 +1,8 @@
{
"name": "openmonetis",
"version": "2.5.6",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@11.1.3",
"scripts": {
"dev": "next dev --turbopack",
"db:seed": "tsx scripts/mock-data.ts",
@@ -31,12 +31,12 @@
"mockup": "tsx scripts/mock-data.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.76",
"@ai-sdk/google": "^3.0.71",
"@ai-sdk/openai": "^3.0.63",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@better-auth/passkey": "^1.6.10",
"@ai-sdk/anthropic": "^3.0.78",
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/openai": "^3.0.64",
"@aws-sdk/client-s3": "^3.1050.0",
"@aws-sdk/s3-request-presigner": "^3.1050.0",
"@better-auth/passkey": "^1.6.11",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -63,26 +63,26 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.177",
"better-auth": "1.6.10",
"ai": "^6.0.185",
"better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"date-fns": "^4.2.1",
"drizzle-orm": "0.45.2",
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"jspdf-autotable": "^5.0.8",
"next": "16.2.6",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.7.284",
"pg": "8.20.0",
"pg": "8.21.0",
"react": "19.2.6",
"react-day-picker": "^10.0.0",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.6",
"recharts": "3.8.1",
"resend": "^6.12.3",
@@ -92,24 +92,20 @@
"vaul": "1.1.2",
"zod": "4.4.3"
},
"pnpm": {
"overrides": {
"defu": "6.1.7"
}
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "4.3.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.6.2",
"@types/node": "25.9.1",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "0.31.10",
"knip": "^6.12.2",
"knip": "^6.14.1",
"tailwindcss": "4.3.0",
"tsx": "4.21.0",
"tsx": "4.22.3",
"typescript": "6.0.3"
}
}

3965
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,17 @@
onlyBuiltDependencies:
- core-js
- esbuild
- sharp
- unrs-resolver
packages:
- '.'
allowBuilds:
core-js: true
esbuild: true
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@aws-sdk/client-s3@3.1050.0'
- '@aws-sdk/s3-request-presigner@3.1050.0'
- '@types/node@25.9.1'
- '@types/react@19.2.15'
overrides:
defu: 6.1.7

BIN
public/logos/bipa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,5 +1,6 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
export default function LoginPage() {
return <LoginForm />;
return <LoginForm signupDisabled={isSignupDisabled()} />;
}

View File

@@ -1,5 +1,11 @@
import { redirect } from "next/navigation";
import { SignupForm } from "@/features/auth/components/signup-form";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
export default function SignupPage() {
if (isSignupDisabled()) {
redirect("/login");
}
return <SignupForm />;
}

View File

@@ -43,6 +43,15 @@ type PageProps = {
const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
const resolveDefaultPaymentMethod = (
accountType: string | null | undefined,
) => {
if (accountType === "Dinheiro") return "Dinheiro";
if (accountType === "Pré-Pago | VR/VA") return "Pré-Pago | VR/VA";
return "Pix";
};
export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { accountId } = await params;
@@ -197,7 +206,11 @@ export default async function Page({ params, searchParams }: PageProps) {
accountId: account.id,
settledOnly: true,
}}
allowCreate={false}
allowCreate
defaultAccountId={account.id}
defaultPaymentMethod={resolveDefaultPaymentMethod(
account.accountType,
)}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}

View File

@@ -134,6 +134,8 @@ export default async function Page({ params, searchParams }: PageProps) {
accountName,
limitInUse: 0,
limitAvailable: limitAmount,
currentInvoiceAmount: 0,
currentInvoiceLabel: "",
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;

View File

@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
dividedFilter: null,
amountMinFilter: null,
amountMaxFilter: null,
dateStartFilter: null,
dateEndFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -1,16 +1,24 @@
import { connection } from "next/server";
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
export default async function Page() {
await connection();
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);
const logoMappings = await prefetchLogoMappings(
user.id,
data.installmentGroups.map((group) => group.name),
);
return (
<main className="flex flex-col gap-4 pb-8">
<InstallmentAnalysisPage data={data} />
<LogoPrefetchProvider mappings={logoMappings}>
<InstallmentAnalysisPage data={data} />
</LogoPrefetchProvider>
</main>
);
}

View File

@@ -30,6 +30,7 @@ import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
export default async function Page() {
const [session, headersList, githubStats] = await Promise.all([
@@ -43,6 +44,7 @@ export default async function Page() {
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
return (
@@ -86,20 +88,23 @@ export default async function Page() {
Entrar
</Button>
</Link>
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</nav>
</NavbarShell>

View File

@@ -41,9 +41,9 @@
--input: var(--border);
--ring: var(--primary);
--chart-1: var(--color-emerald-500);
--chart-2: var(--color-red-500);
--chart-3: var(--color-amber-500);
--chart-1: var(--color-orange-600);
--chart-2: var(--color-orange-400);
--chart-3: var(--color-orange-200);
--chart-4: var(--color-blue-500);
--chart-5: var(--color-pink-500);
--chart-6: var(--color-stone-500);
@@ -117,13 +117,13 @@
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(24.957% 0.00355 48.274);
--border: oklch(31.987% 0.00462 39.069);
--input: var(--border);
--ring: var(--primary);
--chart-1: var(--color-emerald-500);
--chart-2: var(--color-orange-500);
--chart-3: var(--color-indigo-500);
--chart-1: var(--color-orange-600);
--chart-2: var(--color-orange-400);
--chart-3: var(--color-orange-200);
--chart-4: var(--color-amber-500);
--chart-5: var(--color-pink-500);
--chart-6: var(--color-stone-500);

View File

@@ -23,7 +23,7 @@ import {
} from "@/shared/components/ui/dialog";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { useFormState } from "@/shared/hooks/use-form-state";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
import {
formatInitialBalanceInput,
normalizeDecimalInput,
@@ -66,7 +66,7 @@ const buildInitialValues = ({
}): AccountFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
const derivedName = getLogoDisplayName(selectedLogo);
return {
name: account?.name ?? derivedName,

View File

@@ -21,10 +21,18 @@ import { GoogleAuthButton } from "./google-auth-button";
type DivProps = React.ComponentProps<"div">;
interface LoginFormProps extends DivProps {
signupDisabled?: boolean;
}
const authLinkClassName =
"font-medium text-foreground/88 underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/30 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
export function LoginForm({ className, ...props }: DivProps) {
export function LoginForm({
className,
signupDisabled = false,
...props
}: LoginFormProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
@@ -233,12 +241,14 @@ export function LoginForm({ className, ...props }: DivProps) {
</div>
</Field>
<FieldDescription className="pt-1 text-center">
Não tem uma conta?{" "}
<a href="/signup" className={authLinkClassName}>
Inscreva-se
</a>
</FieldDescription>
{!signupDisabled && (
<FieldDescription className="pt-1 text-center">
Não tem uma conta?{" "}
<a href="/signup" className={authLinkClassName}>
Inscreva-se
</a>
</FieldDescription>
)}
<FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}>

View File

@@ -24,7 +24,7 @@ import {
DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS,
} from "@/shared/lib/cards/constants";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
import {
formatLimitInput,
normalizeDecimalInput,
@@ -59,7 +59,7 @@ const buildInitialValues = ({
}): CardFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
const derivedName = getLogoDisplayName(selectedLogo);
return {
name: card?.name ?? derivedName,

View File

@@ -1,6 +1,8 @@
"use client";
import {
RiCalendarCloseLine,
RiCalendarScheduleLine,
RiChat3Line,
RiDeleteBin5Line,
RiFileList2Line,
@@ -33,6 +35,8 @@ interface CardItemProps {
limit: number;
limitInUse?: number;
limitAvailable?: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
accountName: string;
logo?: string | null;
note?: string | null;
@@ -52,6 +56,8 @@ export function CardItem({
limit,
limitInUse,
limitAvailable,
currentInvoiceAmount,
currentInvoiceLabel,
accountName: _accountName,
logo,
note,
@@ -77,7 +83,7 @@ export function CardItem({
return (
<Card className="flex flex-col p-6 w-full">
<CardHeader className="space-y-2 p-0">
<CardHeader className="space-y-1 p-0">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
{logoPath ? (
@@ -146,15 +152,17 @@ export function CardItem({
)}
</div>
<div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
<span>
Fecha em{" "}
<div className="flex items-center justify-between text-sm text-muted-foreground rounded-lg py-4 px-2 bg-primary/5">
<span className="inline-flex items-center gap-1">
<RiCalendarCloseLine className="size-4" aria-hidden />
Fecha{" "}
<span className="font-semibold text-foreground">
dia {formatDay(closingDay)}
</span>
</span>
<span>
Vence em{" "}
<span className="inline-flex items-center gap-1">
<RiCalendarScheduleLine className="size-4" aria-hidden />
Vence{" "}
<span className="font-semibold text-foreground">
dia {formatDay(dueDay)}
</span>
@@ -165,29 +173,40 @@ export function CardItem({
<CardContent className="flex flex-1 flex-col gap-4 px-0">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite disponível
{currentInvoiceLabel}
</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
amount={currentInvoiceAmount}
className="text-xl font-semibold text-info"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<div className="flex gap-2 justify-between w-full">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Limite total</span>
<MoneyValues
amount={limit}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite utilizado
</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-destructive"
className="text-sm font-semibold text-primary"
/>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite disponível
</span>
<MoneyValues
amount={available}
className="text-sm font-semibold text-success"
/>
</div>
</div>
@@ -200,7 +219,7 @@ export function CardItem({
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
{usagePercent.toFixed(0)}% utilizado
</span>
</div>
</CardContent>
@@ -220,7 +239,7 @@ export function CardItem({
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
>
<RiFileList2Line className="size-4" aria-hidden />
ver fatura
fatura
</button>
<button
type="button"

View File

@@ -130,7 +130,7 @@ export function CardsPage({
}
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-1 xl:grid-cols-3">
{list.map((card) => (
<CardItem
key={card.id}
@@ -142,6 +142,8 @@ export function CardsPage({
limit={card.limit}
limitInUse={card.limitInUse ?? null}
limitAvailable={card.limitAvailable ?? card.limit ?? null}
currentInvoiceAmount={card.currentInvoiceAmount}
currentInvoiceLabel={card.currentInvoiceLabel}
accountName={card.accountName}
logo={card.logo}
note={card.note}

View File

@@ -12,6 +12,8 @@ export type Card = {
accountName: string;
limitInUse: number;
limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
};
export type CardFormValues = {

View File

@@ -1,7 +1,23 @@
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm";
import { cards, financialAccounts, transactions } from "@/db/schema";
import {
and,
eq,
ilike,
isNotNull,
isNull,
ne,
not,
or,
sql,
} from "drizzle-orm";
import { cards, financialAccounts, invoices, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import {
formatPeriodMonthShort,
getCurrentPeriod,
parsePeriod,
} from "@/shared/utils/period";
type CardData = {
id: string;
@@ -15,6 +31,8 @@ type CardData = {
limit: number;
limitInUse: number;
limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
accountId: string;
accountName: string;
};
@@ -25,6 +43,11 @@ type AccountSimple = {
logo: string | null;
};
function formatCurrentInvoiceLabel(period: string) {
const { year } = parsePeriod(period);
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
}
async function fetchCardsByStatus(
userId: string,
archived: boolean,
@@ -33,59 +56,94 @@ async function fetchCardsByStatus(
accounts: AccountSimple[];
logoOptions: string[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(
eq(cards.userId, userId),
archived
? ilike(cards.status, "inativo")
: not(ilike(cards.status, "inativo")),
),
with: {
financialAccount: {
columns: {
id: true,
name: true,
const currentPeriod = getCurrentPeriod();
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(
eq(cards.userId, userId),
archived
? ilike(cards.status, "inativo")
: not(ilike(cards.status, "inativo")),
),
with: {
financialAccount: {
columns: {
id: true,
name: true,
},
},
},
},
}),
db.query.financialAccounts.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: eq(financialAccounts.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
}),
db.query.financialAccounts.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: eq(financialAccounts.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
invoices,
and(
eq(invoices.userId, transactions.userId),
eq(invoices.cardId, transactions.cardId),
eq(invoices.period, transactions.period),
),
),
)
.groupBy(transactions.cardId),
]);
)
.where(
and(
eq(transactions.userId, userId),
isNotNull(transactions.cardId),
or(
isNull(invoices.paymentStatus),
ne(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
),
)
.groupBy(transactions.cardId),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.groupBy(transactions.cardId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
if (!row.cardId) return;
usageMap.set(row.cardId, Number(row.total ?? 0));
});
const invoiceMap = new Map<string, number>();
invoiceRows.forEach(
(row: { cardId: string | null; total: number | null }) => {
if (!row.cardId) return;
invoiceMap.set(row.cardId, Math.abs(Number(row.total ?? 0)));
},
);
const cardList = cardRows.map((card) => ({
id: card.id,
@@ -99,13 +157,15 @@ async function fetchCardsByStatus(
limit: Number(card.limit),
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
return Math.abs(total);
})(),
limitAvailable: (() => {
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
const inUse = Math.abs(total);
return Math.max(Number(card.limit) - inUse, 0);
})(),
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
currentInvoiceLabel,
accountId: card.accountId,
accountName:
(card.financialAccount as { name?: string } | null)?.name ??

View File

@@ -330,7 +330,7 @@ export function DashboardGridEditable({
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-xs rounded-lg border border-dashed border-primary flex items-center justify-center">
<div className="absolute inset-0 z-10 bg-background/60 backdrop-blur-[1.5px] rounded-lg border border-dashed border-primary flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-medium">

View File

@@ -8,6 +8,7 @@ import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-c
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
@@ -102,21 +103,22 @@ const getTrend = (current: number, previous: number): Trend => {
return "flat";
};
const getPercentChange = (current: number, previous: number): string => {
const getPercentChange = (current: number, previous: number): string | null => {
const EPSILON = 0.01;
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return "0%";
return "—";
return null;
}
const change = ((current - previous) / Math.abs(previous)) * 100;
if (!Number.isFinite(change)) return "—";
if (!Number.isFinite(change)) return null;
if (Math.abs(change) < TREND_THRESHOLD) return "0%";
if (change > 999) return "+999%";
if (change < -999) return "-999%";
return formatPercentage(change, {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
maximumFractionDigits: 0,
minimumFractionDigits: 0,
signDisplay: "always",
});
};
@@ -160,28 +162,45 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<Separator className="mt-1" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues
className="text-2xl leading-none font-medium"
amount={metric.current}
/>
<PercentageChangeIndicator
trend={trend}
label={percentChange}
positiveTrend={invertTrend ? "down" : "up"}
showFlatIcon
className="gap-1"
iconClassName="size-3.5"
/>
</div>
<CardContent className="flex flex-col">
<div className="flex items-start justify-between mt-1">
<div className="flex flex-col gap-2 min-w-0">
<div className="flex flex-wrap items-center">
<MoneyValues
className="text-2xl leading-none"
amount={metric.current}
/>
</div>
<div className="text-xs text-muted-foreground">
<MoneyValues
className="inline text-xs font-medium text-muted-foreground"
amount={metric.previous}
/>
<span className="ml-1">no mês anterior</span>
<div className="text-xs text-muted-foreground gap-1 flex items-center">
<span className="text-muted-foreground/50">vs</span>
<MoneyValues
className="inline text-xs"
amount={metric.previous}
/>
<Badge
variant="secondary"
aria-hidden={!percentChange}
className={cn(
"w-14 justify-center px-0 text-xs",
!percentChange && "invisible",
)}
>
{percentChange ? (
<PercentageChangeIndicator
trend={trend}
label={percentChange}
positiveTrend={invertTrend ? "down" : "up"}
showFlatIcon={false}
className="shrink-0 justify-center text-center text-xs tabular-nums"
iconClassName="hidden"
/>
) : (
<span className="tabular-nums">0%</span>
)}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>

View File

@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
return (
<div className="flex flex-col gap-4">
{/* Card de resumo principal */}
<Card className="border-none bg-primary/10 dark:bg-primary/10">
<Card className="border-none bg-primary/10 shadow-none">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm text-muted-foreground">
Se você pagar tudo que está selecionado:

View File

@@ -3,13 +3,14 @@
import {
RiBankCard2Line,
RiCheckboxCircleFill,
RiEyeLine,
RiFileList2Line,
RiTimeLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useState } from "react";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
@@ -29,6 +30,7 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Progress } from "@/shared/components/ui/progress";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { cn } from "@/shared/utils";
import type { InstallmentGroup } from "./types";
@@ -62,8 +64,8 @@ export function InstallmentGroupCard({
const hasSelection = selectedInstallments.size > 0;
const progress =
group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100
group.trackedInstallments > 0
? (group.paidInstallments / group.trackedInstallments) * 100
: 0;
const selectedAmount = group.pendingInstallments
@@ -79,6 +81,12 @@ export function InstallmentGroupCard({
(sum, i) => sum + i.amount,
0,
);
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
const cardName = group.cartaoName ?? "Compra parcelada";
const untrackedLabel =
group.untrackedInstallments === 1
? "1 parcela anterior fora do acompanhamento"
: `${group.untrackedInstallments} parcelas anteriores fora do acompanhamento`;
return (
<>
@@ -111,25 +119,24 @@ export function InstallmentGroupCard({
{/* Info principal */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
{group.cartaoLogo ? (
<Image
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName ?? "Cartão"}
width={40}
height={40}
className="size-10 rounded-full object-cover"
/>
) : (
<div className="size-10 flex items-center justify-center">
<RiBankCard2Line className="size-5 text-muted-foreground" />
</div>
)}
<EstablishmentLogo name={group.name} size={40} />
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
{group.name}
</CardTitle>
<CardDescription className="text-xs">
{group.cartaoName ?? "Compra parcelada"}
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
{cardLogoSrc ? (
<Image
src={cardLogoSrc}
alt={`Logo do cartão ${cardName}`}
width={18}
height={18}
className="size-4.5 shrink-0 rounded-full object-cover"
/>
) : (
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">{cardName}</span>
</CardDescription>
</div>
</div>
@@ -147,10 +154,10 @@ export function InstallmentGroupCard({
<CardContent>
{/* Grid de valores */}
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground font-medium">
Valor total
Valor acompanhado
</p>
<MoneyValues
amount={totalAmount}
@@ -165,7 +172,7 @@ export function InstallmentGroupCard({
amount={pendingAmount}
className={cn(
"text-lg font-semibold",
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
pendingAmount > 0 ? "text-primary" : "text-success",
)}
/>
</div>
@@ -177,48 +184,46 @@ export function InstallmentGroupCard({
<div className="flex items-center gap-1 text-muted-foreground">
<RiCheckboxCircleFill className="size-3.5 text-success" />
<span>
{group.paidInstallments} de {group.totalInstallments} parcelas
pagas
{group.paidInstallments} de {group.trackedInstallments}{" "}
parcelas acompanhadas pagas
</span>
</div>
{unpaidCount > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<RiTimeLine className="size-3.5 text-amber-600" />
<RiTimeLine className="size-3.5" />
<span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span>
</div>
)}
</div>
<Progress value={progress} className="h-2.5" />
<Progress
value={progress}
className="h-2.5 bg-muted"
indicatorClassName="bg-success"
/>
{group.untrackedInstallments > 0 && (
<p className="text-xs text-muted-foreground">{untrackedLabel}</p>
)}
</div>
{/* Valor selecionado */}
{hasSelection && (
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
<span className="text-sm font-medium text-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-semibold text-primary"
/>
</div>
)}
{/* Botão para abrir detalhes */}
<Button
type="button"
variant="outline"
variant="secondary"
size="sm"
className="w-full gap-1.5"
className="relative w-full justify-center gap-1.5"
onClick={() => setIsDetailsOpen(true)}
>
<RiEyeLine className="size-4" />
Ver detalhes ({group.pendingInstallments.length} parcelas)
<span className="inline-flex items-center gap-1.5">
<RiFileList2Line className="size-4" />
detalhes
</span>
{hasSelection && (
<span className="absolute right-2 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{selectedInstallments.size} sel.
</span>
)}
</Button>
</CardContent>
</Card>
@@ -228,18 +233,26 @@ export function InstallmentGroupCard({
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<div className="flex items-center gap-3">
{group.cartaoLogo ? (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName ?? "Cartão"}
className="size-8 rounded-full object-cover"
/>
) : (
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
<RiBankCard2Line className="size-4 text-muted-foreground" />
<EstablishmentLogo name={group.name} size={32} />
<div className="min-w-0">
<DialogTitle className="truncate text-base">
{group.name}
</DialogTitle>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{cardLogoSrc ? (
<Image
src={cardLogoSrc}
alt={`Logo do cartão ${cardName}`}
width={14}
height={14}
className="size-3.5 shrink-0 rounded-full object-cover opacity-75"
/>
) : (
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">{cardName}</span>
</div>
)}
<DialogTitle className="text-base">{group.name}</DialogTitle>
</div>
</div>
<DialogDescription className="sr-only">
Detalhes das parcelas do grupo {group.name}

View File

@@ -19,15 +19,15 @@ type IncomeExpenseBalanceWidgetProps = {
const chartConfig = {
receita: {
label: "Receita",
color: "var(--success)",
color: "var(--chart-1)",
},
despesa: {
label: "Despesa",
color: "var(--destructive)",
color: "var(--chart-2)",
},
balanco: {
label: "Balanço",
color: "var(--warning)",
color: "var(--chart-3)",
},
} satisfies ChartConfig;

View File

@@ -51,6 +51,9 @@ export type InstallmentGroup = {
cartaoDueDay: string | null;
cartaoLogo: string | null;
totalInstallments: number;
trackedStartInstallment: number;
trackedInstallments: number;
untrackedInstallments: number;
paidInstallments: number;
pendingInstallments: InstallmentDetail[];
totalPendingAmount: number;
@@ -92,7 +95,10 @@ export async function fetchInstallmentAnalysis(
cartaoLogo: cards.logo,
})
.from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(
cards,
and(eq(transactions.cardId, cards.id), eq(cards.userId, userId)),
)
.where(
and(
eq(transactions.userId, userId),
@@ -150,6 +156,12 @@ export async function fetchInstallmentAnalysis(
cartaoDueDay: row.cartaoDueDay,
cartaoLogo: row.cartaoLogo,
totalInstallments: row.installmentCount ?? 0,
trackedStartInstallment: installmentDetail.currentInstallment,
trackedInstallments: 1,
untrackedInstallments: Math.max(
0,
installmentDetail.currentInstallment - 1,
),
paidInstallments: 0,
pendingInstallments: [installmentDetail],
totalPendingAmount: amount,
@@ -165,7 +177,13 @@ export async function fetchInstallmentAnalysis(
const paidCount = group.pendingInstallments.filter(
(i) => i.isSettled,
).length;
const trackedStartInstallment = Math.min(
...group.pendingInstallments.map((i) => i.currentInstallment),
);
group.paidInstallments = paidCount;
group.trackedStartInstallment = trackedStartInstallment;
group.trackedInstallments = group.pendingInstallments.length;
group.untrackedInstallments = Math.max(0, trackedStartInstallment - 1);
return group;
})
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
@@ -174,6 +192,22 @@ export async function fetchInstallmentAnalysis(
(i) => !i.isSettled,
);
return hasUnpaidInstallments;
})
.sort((a, b) => {
const progressA =
a.trackedInstallments > 0
? a.paidInstallments / a.trackedInstallments
: 0;
const progressB =
b.trackedInstallments > 0
? b.paidInstallments / b.trackedInstallments
: 0;
if (progressA !== progressB) {
return progressB - progressA;
}
return a.firstPurchaseDate.getTime() - b.firstPurchaseDate.getTime();
});
// Calcular totais

View File

@@ -274,15 +274,14 @@ const buildPaymentStatusData = (
continue;
}
const target =
row.transactionType === TRANSACTION_TYPE_INCOME
? result.income
: result.expenses;
const isExpense = row.transactionType === TRANSACTION_TYPE_EXPENSE;
const target = isExpense ? result.expenses : result.income;
const displayAmount = isExpense ? Math.abs(amount) : amount;
if (row.isSettled === true) {
target.confirmed += amount;
target.confirmed += displayAmount;
} else {
target.pending += amount;
target.pending += displayAmount;
}
}

View File

@@ -213,8 +213,8 @@ export const InboxCard = memo(function InboxCard({
variant="ghost"
onClick={() => onViewDetails?.(item)}
className="text-muted-foreground hover:text-foreground"
aria-label="Ver detalhes"
title="Ver detalhes"
aria-label="detalhes"
title="detalhes"
>
<RiFileList2Line className="size-4" />
</Button>

View File

@@ -23,9 +23,14 @@ const navLinks = [
interface MobileNavProps {
isPublicDomain: boolean;
isLoggedIn: boolean;
signupDisabled: boolean;
}
export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
export function MobileNav({
isPublicDomain,
isLoggedIn,
signupDisabled,
}: MobileNavProps) {
const [open, setOpen] = useState(false);
return (
@@ -75,12 +80,14 @@ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
Entrar
</Button>
</Link>
<Link href="/signup" onClick={() => setOpen(false)}>
<Button className="w-full gap-2">
Começar
<RiArrowRightSLine size={16} />
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup" onClick={() => setOpen(false)}>
<Button className="w-full gap-2">
Começar
<RiArrowRightSLine size={16} />
</Button>
</Link>
)}
</>
)}
</div>

View File

@@ -1,6 +1,10 @@
"use client";
import { RiAddCircleFill, RiDeleteBinLine } from "@remixicon/react";
import {
RiAddCircleFill,
RiCheckLine,
RiDeleteBinLine,
} from "@remixicon/react";
import {
type ReactNode,
useEffect,
@@ -69,10 +73,13 @@ export function NoteDialog({
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [newTaskText, setNewTaskText] = useState("");
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [editingTaskText, setEditingTaskText] = useState("");
const titleRef = useRef<HTMLInputElement>(null);
const descRef = useRef<HTMLTextAreaElement>(null);
const newTaskRef = useRef<HTMLInputElement>(null);
const editingTaskRef = useRef<HTMLInputElement>(null);
const [dialogOpen, setDialogOpen] = useControlledState(
open,
@@ -90,6 +97,8 @@ export function NoteDialog({
resetForm(buildInitialValues(note));
setErrorMessage(null);
setNewTaskText("");
setEditingTaskId(null);
setEditingTaskText("");
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, resetForm]);
@@ -126,7 +135,12 @@ export function NoteDialog({
formState.description.trim() === (note?.description ?? "").trim() &&
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const disableSubmit =
isPending ||
onlySpaces ||
unchanged ||
invalidLen ||
Boolean(editingTaskId);
const handleOpenChange = (v: boolean) => {
setDialogOpen(v);
@@ -159,6 +173,10 @@ export function NoteDialog({
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
if (editingTaskId === taskId) {
setEditingTaskId(null);
setEditingTaskText("");
}
};
const handleToggleTask = (taskId: string) => {
@@ -170,6 +188,40 @@ export function NoteDialog({
);
};
const handleStartEditTask = (task: Task) => {
if (isPending) return;
setEditingTaskId(task.id);
setEditingTaskText(task.text);
requestAnimationFrame(() => {
editingTaskRef.current?.focus();
editingTaskRef.current?.select();
});
};
const handleSaveTask = (taskId: string) => {
const text = normalize(editingTaskText);
if (!text) {
toast.error("O texto da tarefa não pode estar vazio.");
editingTaskRef.current?.focus();
return;
}
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, text } : t,
),
);
setEditingTaskId(null);
setEditingTaskText("");
};
const handleCancelEditTask = () => {
setEditingTaskId(null);
setEditingTaskText("");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
@@ -373,33 +425,78 @@ export function NoteDialog({
key={task.id}
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
>
<Checkbox
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
checked={task.completed}
onCheckedChange={() => handleToggleTask(task.id)}
disabled={isPending}
aria-label={`Marcar "${task.text}" como ${
task.completed ? "não concluída" : "concluída"
}`}
/>
<span
className={cn(
"flex-1 text-sm wrap-break-word",
task.completed
? "text-muted-foreground line-through"
: "text-foreground",
)}
>
{task.text}
</span>
{editingTaskId === task.id ? (
<Input
ref={editingTaskRef}
value={editingTaskText}
onChange={(e) => setEditingTaskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleSaveTask(task.id);
}
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
handleCancelEditTask();
}
}}
disabled={isPending}
className="h-8 min-w-0 flex-1"
aria-label={`Editar "${task.text}"`}
/>
) : (
<>
<Checkbox
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
checked={task.completed}
onCheckedChange={() => handleToggleTask(task.id)}
disabled={isPending}
aria-label={`Marcar "${task.text}" como ${
task.completed ? "não concluída" : "concluída"
}`}
/>
<button
type="button"
onClick={() => handleStartEditTask(task)}
disabled={isPending}
className={cn(
"min-w-0 flex-1 cursor-text text-left text-sm wrap-break-word transition-colors hover:text-primary disabled:cursor-not-allowed",
task.completed
? "text-muted-foreground line-through"
: "text-foreground",
)}
>
{task.text}
</button>
</>
)}
<button
type="button"
onClick={() => handleRemoveTask(task.id)}
onClick={() =>
editingTaskId === task.id
? handleSaveTask(task.id)
: handleRemoveTask(task.id)
}
disabled={isPending}
className="shrink-0 text-muted-foreground/50 hover:text-destructive transition-colors"
aria-label={`Remover "${task.text}"`}
className={cn(
"shrink-0 transition-colors",
editingTaskId === task.id
? "text-success hover:text-success/80"
: "text-muted-foreground/50 hover:text-destructive",
)}
aria-label={
editingTaskId === task.id
? `Salvar "${task.text}"`
: `Remover "${task.text}"`
}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
{editingTaskId === task.id ? (
<RiCheckLine className="h-4 w-4" />
) : (
<RiDeleteBinLine className="h-3.5 w-3.5" />
)}
</button>
</div>
))}

View File

@@ -169,7 +169,7 @@ export function DeleteAccountForm() {
>
<DialogHeader>
<DialogTitle>
{isResetAction ? "Zerar sua conta?" : "Você tem certeza?"}
{isResetAction ? "ZERAR sua conta?" : "Você tem certeza?"}
</DialogTitle>
<DialogDescription>
{isResetAction

View File

@@ -26,6 +26,7 @@ import type {
import { uuidSchema } from "@/shared/lib/schemas/common";
import type { ActionResult } from "@/shared/lib/types/actions";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { comparePeriods } from "@/shared/utils/period";
/**
* Schema de validação para criar antecipação
@@ -63,14 +64,18 @@ const cancelAnticipationSchema = z.object({
*/
export async function getEligibleInstallmentsAction(
seriesId: string,
anticipationPeriod: string,
): Promise<ActionResult<EligibleInstallment[]>> {
try {
const user = await getUser();
// Validar seriesId
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
const validatedAnticipationPeriod =
createAnticipationSchema.shape.anticipationPeriod.parse(
anticipationPeriod,
);
// Buscar todas as parcelas da série que estão elegíveis
const rows = await db.query.transactions.findMany({
where: and(
eq(transactions.seriesId, validatedSeriesId),
@@ -96,19 +101,23 @@ export async function getEligibleInstallmentsAction(
},
});
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
id: row.id,
name: row.name,
amount: row.amount,
period: row.period,
purchaseDate: row.purchaseDate,
dueDate: row.dueDate,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
paymentMethod: row.paymentMethod,
categoryId: row.categoryId,
payerId: row.payerId,
}));
const eligibleInstallments: EligibleInstallment[] = rows
.filter(
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
)
.map((row) => ({
id: row.id,
name: row.name,
amount: row.amount,
period: row.period,
purchaseDate: row.purchaseDate,
dueDate: row.dueDate,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
paymentMethod: row.paymentMethod,
categoryId: row.categoryId,
payerId: row.payerId,
}));
return {
success: true,
@@ -195,6 +204,18 @@ export async function createInstallmentAnticipationAction(
};
}
const selectedIncludesCurrentOrPastPeriod = installments.some(
(installment) =>
comparePeriods(installment.period, data.anticipationPeriod) <= 0,
);
if (selectedIncludesCurrentOrPastPeriod) {
return {
success: false,
error: "Selecione apenas parcelas de períodos futuros para antecipar.",
};
}
// 2. Calcular valor total
const totalAmountCents = installments.reduce(
(sum, inst) => sum + Number(inst.amount) * 100,

View File

@@ -7,6 +7,7 @@ import {
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
@@ -30,6 +31,7 @@ import {
fetchOwnedPayerIds,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
isInitialBalanceTransaction,
type MassAddInput,
massAddSchema,
resolvePeriod,
@@ -47,6 +49,19 @@ const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
return (target.year - base.year) * 12 + (target.month - base.month);
};
type ProtectedTransactionCandidate = {
note: string | null;
transactionType: string | null;
condition: string | null;
paymentMethod: string | null;
};
const isProtectedTransaction = (
record: ProtectedTransactionCandidate,
): boolean =>
Boolean(record.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
isInitialBalanceTransaction(record);
export async function deleteTransactionBulkAction(
input: DeleteBulkInput,
): Promise<ActionResult> {
@@ -61,6 +76,9 @@ export async function deleteTransactionBulkAction(
seriesId: true,
period: true,
condition: true,
transactionType: true,
paymentMethod: true,
note: true,
},
where: and(
eq(transactions.id, data.id),
@@ -79,6 +97,13 @@ export async function deleteTransactionBulkAction(
};
}
if (isProtectedTransaction(existing)) {
return {
success: false,
error: "Lançamentos protegidos não podem ser removidos em massa.",
};
}
let scopeFilter: ReturnType<typeof and>;
let successMessage: string;
@@ -171,6 +196,7 @@ export async function updateTransactionBulkAction(
purchaseDate: true,
payerId: true,
cardId: true,
note: true,
},
where: and(
eq(transactions.id, data.id),
@@ -189,6 +215,13 @@ export async function updateTransactionBulkAction(
};
}
if (isProtectedTransaction(existing)) {
return {
success: false,
error: "Lançamentos protegidos não podem ser atualizados em massa.",
};
}
const baseUpdatePayload: Record<string, unknown> = {
name: data.name,
categoryId: data.categoryId ?? null,
@@ -753,6 +786,13 @@ export async function deleteMultipleTransactionsAction(
return { success: false, error: "Nenhum lançamento encontrado." };
}
if (existing.some(isProtectedTransaction)) {
return {
success: false,
error: "Lançamentos protegidos não podem ser removidos em massa.",
};
}
const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(transactionAttachments)

View File

@@ -335,6 +335,12 @@ const baseFields = z.object({
.min(1, "Selecione uma quantidade válida.")
.max(60, "Selecione uma quantidade válida.")
.optional(),
startInstallment: z.coerce
.number()
.int()
.min(1, "Selecione uma parcela válida.")
.max(60, "Selecione uma parcela válida.")
.optional(),
recurrenceCount: z.coerce
.number()
.int()
@@ -415,6 +421,15 @@ const refineLancamento = (
path: ["installmentCount"],
message: "Selecione pelo menos duas parcelas.",
});
} else if (
data.startInstallment &&
data.startInstallment > data.installmentCount
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["startInstallment"],
message: "A parcela inicial não pode ser maior que o total.",
});
}
}
@@ -651,24 +666,27 @@ export const buildTransactionRecords = ({
if (data.condition === "Parcelado") {
const installmentTotal = data.installmentCount ?? 0;
const startInstallment = data.startInstallment ?? 1;
const amountsByShare = shares.map((share) =>
splitAmount(share.amountCents, installmentTotal),
);
for (
let installment = 0;
installment < installmentTotal;
installment += 1
let index = 0;
index <= installmentTotal - startInstallment;
index += 1
) {
const installmentPeriod = addMonthsToPeriod(period, installment);
const currentInstallment = startInstallment + index;
const installmentPeriod = addMonthsToPeriod(period, index);
const installmentDueDate = dueDate
? addMonthsToDate(dueDate, installment)
? addMonthsToDate(dueDate, index)
: null;
const splitGroupId = cycleSplitGroupId();
shares.forEach((share, shareIndex) => {
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
const settled = resolveSettledValue(installment);
const amountCents =
amountsByShare[shareIndex]?.[currentInstallment - 1] ?? 0;
const settled = resolveSettledValue(index);
records.push({
...basePayload,
amount: centsToDecimalString(amountCents * amountSign),
@@ -677,7 +695,7 @@ export const buildTransactionRecords = ({
period: installmentPeriod,
isSettled: settled,
installmentCount: installmentTotal,
currentInstallment: installment + 1,
currentInstallment,
recurrenceCount: null,
dueDate: installmentDueDate,
splitGroupId,

View File

@@ -36,6 +36,14 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
dividedFilter: z.string().nullable(),
amountMinFilter: z.number().nullable(),
amountMaxFilter: z.number().nullable(),
dateStartFilter: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable(),
dateEndFilter: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable(),
}),
accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(),

View File

@@ -4,9 +4,10 @@ import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { transactions } from "@/db/schema";
import {
fetchOwnedCategoryIds,
fetchOwnedPayerIds,
validateCartaoOwnership,
validateContaOwnership,
validatePayerOwnership,
} from "@/features/transactions/actions/core";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
@@ -21,6 +22,7 @@ const importRowSchema = z.object({
description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]),
categoryId: uuidSchema("Category").nullable().optional(),
payerId: uuidSchema("Payer").nullable().optional(),
});
const importSchema = z.object({
@@ -76,14 +78,34 @@ export async function importTransactionsAction(
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
// Valida ownership
const [payerOk, accountOk, cardOk] = await Promise.all([
validatePayerOwnership(userId, payerId),
validateContaOwnership(userId, accountId),
validateCartaoOwnership(userId, cardId),
]);
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
if (payerIdsByRow.some((id) => !id)) {
return { success: false, error: "Pessoa obrigatória." };
}
// Valida ownership
const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
await Promise.all([
fetchOwnedPayerIds(userId, payerIdsByRow),
fetchOwnedCategoryIds(
userId,
rows.map((row) => row.categoryId),
),
validateContaOwnership(userId, accountId),
validateCartaoOwnership(userId, cardId),
]);
if (payerIdsByRow.some((id) => id && !ownedPayerIds.has(id))) {
return { success: false, error: "Pessoa não encontrada." };
}
if (
rows.some((row) => row.categoryId && !ownedCategoryIds.has(row.categoryId))
) {
return { success: false, error: "Categoria não encontrada." };
}
if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
@@ -96,7 +118,7 @@ export async function importTransactionsAction(
// Cartão de crédito: fatura pode ainda não ter sido paga
const isSettled = paymentMethod !== "Cartão de crédito";
const records = rows.map((row) => {
const records = rows.map((row, index) => {
const purchaseDate = parseLocalDateString(row.date);
const period =
invoicePeriod ??
@@ -115,7 +137,7 @@ export async function importTransactionsAction(
period,
isSettled,
userId,
payerId: payerId ?? null,
payerId: payerIdsByRow[index],
accountId: accountId ?? null,
cardId: cardId ?? null,
categoryId: row.categoryId ?? null,

View File

@@ -8,6 +8,7 @@ import {
transactionAttachments,
transactions,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
@@ -230,13 +231,6 @@ export async function updateTransactionAction(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
with: {
category: {
columns: {
name: true,
},
},
},
})) as
| {
id: string;
@@ -248,7 +242,6 @@ export async function updateTransactionAction(
accountId: string | null;
cardId: string | null;
categoryId: string | null;
category: { name: string } | null;
}
| undefined;
@@ -256,14 +249,17 @@ export async function updateTransactionAction(
return { success: false, error: "Lançamento não encontrado." };
}
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
if (
existing.category?.name &&
categoriasProtegidasEdicao.includes(existing.category.name)
) {
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`,
error: "Pagamentos automáticos de fatura não podem ser editados.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser editados.",
};
}
@@ -391,13 +387,6 @@ export async function deleteTransactionAction(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
with: {
category: {
columns: {
name: true,
},
},
},
})) as
| {
id: string;
@@ -411,7 +400,6 @@ export async function deleteTransactionAction(
period: string;
note: string | null;
categoryId: string | null;
category: { name: string } | null;
}
| undefined;
@@ -419,14 +407,17 @@ export async function deleteTransactionAction(
return { success: false, error: "Lançamento não encontrado." };
}
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
if (
existing.category?.name &&
categoriasProtegidasRemocao.includes(existing.category.name)
) {
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`,
error: "Pagamentos automáticos de fatura não podem ser removidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser removidos.",
};
}

View File

@@ -1,13 +1,18 @@
"use client";
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
import { useRef } from "react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config";
import { Button } from "@/shared/components/ui/button";
import {
getFilesFromClipboard,
isTextEditingTarget,
validateAttachmentFile,
} from "./attachment-file-utils";
interface AttachmentFilePickerProps {
files: File[];
@@ -22,34 +27,54 @@ export function AttachmentFilePicker({
onRemove,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null);
function addFile(file: File) {
const validation = validateAttachmentFile(file, maxSizeMb);
if (!validation.ok) {
toast.error(validation.error);
return;
}
onAdd(file);
}
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;
}
onAdd(selected);
addFile(selected);
}
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
const pastedFiles = getFilesFromClipboard(event);
if (pastedFiles.length === 0) return;
event.preventDefault();
for (const file of pastedFiles) {
addFile(file);
}
}
useEffect(() => {
function handleDocumentPaste(event: ClipboardEvent) {
if (isTextEditingTarget(event.target)) return;
const pastedFiles = getFilesFromClipboard(event);
if (pastedFiles.length === 0) return;
event.preventDefault();
for (const file of pastedFiles) {
addFile(file);
}
}
document.addEventListener("paste", handleDocumentPaste);
return () => document.removeEventListener("paste", handleDocumentPaste);
});
return (
<div className="space-y-1.5">
<p className="text-xs font-medium">Anexos</p>
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
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()}
onPaste={handlePaste}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
MB
</span>
</button>
</div>

View File

@@ -0,0 +1,54 @@
import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config";
type AttachmentValidationResult = { ok: true } | { ok: false; error: string };
export function validateAttachmentFile(
file: File,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
): AttachmentValidationResult {
if (
!ALLOWED_MIME_TYPES.includes(
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
return {
ok: false,
error:
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
};
}
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
if (file.size > maxFileSizeBytes) {
return { ok: false, error: `O arquivo deve ter no máximo ${maxSizeMb}MB.` };
}
return { ok: true };
}
type ClipboardLikeEvent = ClipboardEvent | React.ClipboardEvent;
export function getFilesFromClipboard(event: ClipboardLikeEvent): File[] {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length > 0) return files;
return Array.from(event.clipboardData?.items ?? [])
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
}
export function isTextEditingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
target.isContentEditable ||
target.closest('[contenteditable="true"]') !== null
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { RiAttachment2 } from "@remixicon/react";
import { useRef, useTransition } from "react";
import { useEffect, useRef, useTransition } from "react";
import { toast } from "sonner";
import {
confirmAttachmentUploadAction,
@@ -11,6 +11,11 @@ import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config";
import {
getFilesFromClipboard,
isTextEditingTarget,
validateAttachmentFile,
} from "./attachment-file-utils";
interface AttachmentUploadProps {
transactionId: string;
@@ -25,7 +30,6 @@ export function AttachmentUpload({
onPendingUpload,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentUploadProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
@@ -36,19 +40,13 @@ export function AttachmentUpload({
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;
}
handleFile(file);
}
if (file.size > maxFileSizeBytes) {
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
function handleFile(file: File) {
const validation = validateAttachmentFile(file, maxSizeMb);
if (!validation.ok) {
toast.error(validation.error);
return;
}
@@ -94,6 +92,29 @@ export function AttachmentUpload({
});
}
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
const [file] = getFilesFromClipboard(event);
if (!file) return;
event.preventDefault();
handleFile(file);
}
useEffect(() => {
function handleDocumentPaste(event: ClipboardEvent) {
if (isPending || isTextEditingTarget(event.target)) return;
const [file] = getFilesFromClipboard(event);
if (!file) return;
event.preventDefault();
handleFile(file);
}
document.addEventListener("paste", handleDocumentPaste);
return () => document.removeEventListener("paste", handleDocumentPaste);
});
return (
<>
<input
@@ -107,6 +128,7 @@ export function AttachmentUpload({
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()}
onPaste={handlePaste}
disabled={isPending}
>
<span className="flex items-center gap-2">
@@ -115,7 +137,8 @@ export function AttachmentUpload({
</span>
{!isPending && (
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
MB
</span>
)}
</button>

View File

@@ -1,6 +1,7 @@
"use client";
import { RiLoader4Line } from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { CategoryIcon } from "@/features/categories/components/category-icon";
@@ -8,6 +9,7 @@ import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
} from "@/features/transactions/actions/anticipation";
import { installmentAnticipationsQueryKey } from "@/features/transactions/hooks/use-installment-anticipations";
import MoneyValues from "@/shared/components/money-values";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Button } from "@/shared/components/ui/button";
@@ -70,6 +72,7 @@ export function AnticipateInstallmentsDialog({
open,
onOpenChange,
}: AnticipateInstallmentsDialogProps) {
const queryClient = useQueryClient();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
@@ -86,7 +89,7 @@ export function AnticipateInstallmentsDialog({
);
// Use form state hook for form management
const { formState, replaceForm, updateField } =
const { formState, replaceForm, updateField, updateFields } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: "0",
@@ -95,15 +98,34 @@ export function AnticipateInstallmentsDialog({
note: "",
});
// Buscar parcelas elegíveis ao abrir o dialog
// Resetar formulário ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
setSelectedIds([]);
setErrorMessage(null);
replaceForm({
anticipationPeriod: defaultPeriod,
discount: "0",
payerId: "",
categoryId: "",
note: "",
});
}
}, [defaultPeriod, dialogOpen, replaceForm]);
// Buscar parcelas elegíveis ao abrir o dialog e ao trocar o período
useEffect(() => {
if (dialogOpen) {
let shouldUpdate = true;
setIsLoadingInstallments(true);
setSelectedIds([]);
setErrorMessage(null);
getEligibleInstallmentsAction(seriesId)
getEligibleInstallmentsAction(seriesId, formState.anticipationPeriod)
.then((result) => {
if (!shouldUpdate) return;
if (!result.success) {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
@@ -116,25 +138,30 @@ export function AnticipateInstallmentsDialog({
// Pré-preencher pagador e categoria da primeira parcela
if (installments.length > 0) {
const first = installments[0];
replaceForm({
anticipationPeriod: defaultPeriod,
discount: "0",
updateFields({
payerId: first.payerId ?? "",
categoryId: first.categoryId ?? "",
note: "",
});
}
})
.catch((error) => {
if (!shouldUpdate) return;
console.error("Erro ao buscar parcelas:", error);
toast.error("Erro ao carregar parcelas elegíveis");
setEligibleInstallments([]);
})
.finally(() => {
if (!shouldUpdate) return;
setIsLoadingInstallments(false);
});
return () => {
shouldUpdate = false;
};
}
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
}, [dialogOpen, formState.anticipationPeriod, seriesId, updateFields]);
const totalAmount = useMemo(() => {
return eligibleInstallments
@@ -189,6 +216,9 @@ export function AnticipateInstallmentsDialog({
if (result.success) {
toast.success(result.message);
void queryClient.invalidateQueries({
queryKey: installmentAnticipationsQueryKey(seriesId),
});
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";

View File

@@ -1,16 +1,21 @@
"use client";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
import {
installmentAnticipationsQueryKey,
useInstallmentAnticipations,
} from "@/features/transactions/hooks/use-installment-anticipations";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -31,7 +36,6 @@ interface AnticipationHistoryDialogProps {
lancamentoName: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onViewLancamento?: (transactionId: string) => void;
}
export function AnticipationHistoryDialog({
@@ -40,7 +44,6 @@ export function AnticipationHistoryDialog({
lancamentoName,
open,
onOpenChange,
onViewLancamento,
}: AnticipationHistoryDialogProps) {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useControlledState(
@@ -51,87 +54,152 @@ export function AnticipationHistoryDialog({
const {
data: anticipations = [],
isLoading,
isFetching,
isError,
refetch,
} = useInstallmentAnticipations(seriesId, dialogOpen);
const handleCanceled = () => {
useEffect(() => {
if (dialogOpen) {
void refetch();
}
}, [dialogOpen, refetch]);
const cancelableAnticipation = anticipations.find(
(anticipation) => anticipation.transaction?.isSettled !== true,
);
const anticipationCountLabel =
anticipations.length === 1
? "1 registro de antecipação encontrada"
: `${anticipations.length} registros de antecipações encontradas`;
const refreshHistory = () => {
void queryClient.invalidateQueries({
queryKey: installmentAnticipationsQueryKey(seriesId),
});
};
const handleCancelAnticipation = async () => {
if (!cancelableAnticipation) return;
const result = await cancelInstallmentAnticipationAction({
anticipationId: cancelableAnticipation.id,
});
if (result.success) {
toast.success(result.message);
refreshHistory();
return;
}
toast.error(result.error || "Erro ao cancelar antecipação");
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="min-w-0 overflow-x-hidden">
<DialogHeader className="text-left">
<DialogTitle>Histórico de Antecipações</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
{isLoading ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
{isLoading || isFetching ? (
<LoadingState />
) : isError ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Não foi possível carregar</EmptyTitle>
<EmptyDescription>
O histórico de antecipações não pôde ser carregado agora.
</EmptyDescription>
</EmptyHeader>
<Button
type="button"
variant="outline"
className="mx-auto"
onClick={() => void refetch()}
>
Tentar novamente
</Button>
</Empty>
<ErrorState onRetry={() => void refetch()} />
) : anticipations.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada
aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
<EmptyState />
) : (
anticipations.map((anticipation) => (
<AnticipationCard
key={anticipation.id}
anticipation={anticipation}
onViewLancamento={onViewLancamento}
onCanceled={handleCanceled}
/>
))
<div className="min-w-0 space-y-3">
<p className="text-left text-muted-foreground text-primary">
{anticipationCountLabel}
</p>
{anticipations.map((anticipation) => (
<AnticipationCard
key={anticipation.id}
anticipation={anticipation}
/>
))}
</div>
)}
</div>
{!isLoading && anticipations.length > 0 && (
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
{anticipations.length}{" "}
{anticipations.length === 1
? "antecipação encontrada"
: "antecipações encontradas"}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
{cancelableAnticipation ? (
<ConfirmActionDialog
trigger={
<Button type="button" variant="destructive">
Desfazer Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancelAnticipation}
/>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function LoadingState() {
return (
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Não foi possível carregar</EmptyTitle>
<EmptyDescription>
O histórico de antecipações não pôde ser carregado agora.
</EmptyDescription>
</EmptyHeader>
<Button
type="button"
variant="outline"
className="mx-auto"
onClick={onRetry}
>
Tentar novamente
</Button>
</Empty>
);
}
function EmptyState() {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View File

@@ -49,7 +49,8 @@ export function InstallmentSelectionTable({
Nenhuma parcela elegível para antecipação encontrada.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Todas as parcelas desta compra foram pagas ou antecipadas.
Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem
aqui.
</p>
</div>
);

View File

@@ -8,6 +8,7 @@ import {
PAYMENT_METHODS,
type TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { DatePicker } from "@/shared/components/ui/date-picker";
@@ -123,10 +124,11 @@ interface TransactionRow {
function createEmptyTransactionRow(
defaultPayerId?: string | null,
lastPurchaseDate?: string,
): TransactionRow {
return {
id: createClientSafeId(),
purchaseDate: getTodayDateString(),
purchaseDate: lastPurchaseDate ?? getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
@@ -148,6 +150,9 @@ export function MassAddDialog({
defaultCardId,
}: MassAddDialogProps) {
const [loading, setLoading] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] =
@@ -179,11 +184,23 @@ export function MassAddDialog({
return groupAndSortCategories(filtered);
}, [categoryOptions, transactionType]);
const resetForm = () => {
setTransactionType("Despesa");
setPaymentMethod(PAYMENT_METHODS[0]);
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(defaultCardId ?? undefined);
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
setIsDirty(false);
};
const addTransaction = () => {
const lastTransaction = transactions[transactions.length - 1];
setTransactions([
...transactions,
createEmptyTransactionRow(defaultPayerId),
createEmptyTransactionRow(defaultPayerId, lastTransaction?.purchaseDate),
]);
setIsDirty(true);
};
const removeTransaction = (id: string) => {
@@ -192,6 +209,7 @@ export function MassAddDialog({
return;
}
setTransactions(transactions.filter((t) => t.id !== id));
setIsDirty(true);
};
const updateTransaction = (
@@ -202,6 +220,7 @@ export function MassAddDialog({
setTransactions(
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
);
setIsDirty(true);
};
const handleSubmit = async () => {
@@ -250,13 +269,7 @@ export function MassAddDialog({
try {
await onSubmit(formData);
onOpenChange(false);
// Reset form
setTransactionType("Despesa");
setPaymentMethod(PAYMENT_METHODS[0]);
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(defaultCardId ?? undefined);
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
resetForm();
} catch (_error) {
// Error is handled by the onSubmit function
} finally {
@@ -265,7 +278,19 @@ export function MassAddDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && isDirty) {
setConfirmCloseOpen(true);
} else {
onOpenChange(newOpen);
if (newOpen === false) {
resetForm();
}
}
}}
>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
<DialogHeader>
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
@@ -286,9 +311,10 @@ export function MassAddDialog({
<Label htmlFor="transaction-type">Tipo de Transação</Label>
<Select
value={transactionType}
onValueChange={(value) =>
setTransactionType(value as MassAddTransactionType)
}
onValueChange={(value) => {
setTransactionType(value as MassAddTransactionType);
setIsDirty(true);
}}
>
<SelectTrigger id="transaction-type" className="w-full">
<SelectValue>
@@ -315,6 +341,7 @@ export function MassAddDialog({
value={paymentMethod}
onValueChange={(value) => {
setPaymentMethod(value as MassAddPaymentMethod);
setIsDirty(true);
// Reset conta/cartao when changing payment method
if (value === "Cartão de crédito") {
setContaId(undefined);
@@ -346,7 +373,10 @@ export function MassAddDialog({
<Label htmlFor="cartao">Cartão</Label>
<Select
value={cardId}
onValueChange={setCartaoId}
onValueChange={(value) => {
setCartaoId(value);
setIsDirty(true);
}}
disabled={isLockedToCartao}
>
<SelectTrigger id="cartao" className="w-full">
@@ -395,7 +425,10 @@ export function MassAddDialog({
{cardId ? (
<InlinePeriodPicker
period={period}
onPeriodChange={setPeriod}
onPeriodChange={(value) => {
setPeriod(value);
setIsDirty(true);
}}
/>
) : null}
</div>
@@ -405,7 +438,13 @@ export function MassAddDialog({
{!isCartaoSelected ? (
<div className="space-y-2">
<Label htmlFor="conta">Conta</Label>
<Select value={accountId} onValueChange={setContaId}>
<Select
value={accountId}
onValueChange={(value) => {
setContaId(value);
setIsDirty(true);
}}
>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{accountId &&
@@ -635,7 +674,13 @@ export function MassAddDialog({
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={() => {
if (isDirty) {
setCancelConfirmOpen(true);
} else {
onOpenChange(false);
}
}}
disabled={loading}
>
Cancelar
@@ -646,6 +691,36 @@ export function MassAddDialog({
{transactions.length === 1 ? "lançamento" : "lançamentos"}
</Button>
</DialogFooter>
<ConfirmActionDialog
open={confirmCloseOpen}
onOpenChange={setConfirmCloseOpen}
title="Descartar alterações?"
description="Há lançamentos não salvos. Se fechar agora, todos os dados serão perdidos."
confirmLabel="Descartar"
cancelLabel="Continuar editando"
confirmVariant="destructive"
onConfirm={() => {
setConfirmCloseOpen(false);
onOpenChange(false);
resetForm();
}}
/>
<ConfirmActionDialog
open={cancelConfirmOpen}
onOpenChange={setCancelConfirmOpen}
title="Cancelar adição de lançamentos?"
description="Há lançamentos não salvos. Se cancelar, todos os dados serão perdidos."
confirmLabel="Cancelar"
cancelLabel="Continuar editando"
confirmVariant="destructive"
onConfirm={() => {
setCancelConfirmOpen(false);
onOpenChange(false);
resetForm();
}}
/>
</DialogContent>
</Dialog>
);

View File

@@ -10,9 +10,9 @@ import {
import {
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
} from "@/features/transactions/lib/formatting-helpers";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import {
Avatar,
@@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
import { parseLocalDateString } from "@/shared/utils/date";
import { formatDate, parseLocalDateString } from "@/shared/utils/date";
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
import { AttachmentSection } from "../attachments/attachment-section";
import { InstallmentTimeline } from "../shared/installment-timeline";
@@ -55,10 +55,9 @@ export function TransactionDetailsDialog({
}: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
useEffect(() => {
setAttachmentCount(null);
}, [transaction?.id]);
}, []);
if (!transaction) return null;
@@ -87,11 +86,16 @@ export function TransactionDetailsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
<DialogHeader>
<DialogTitle>{transaction.name}</DialogTitle>
<DialogDescription>
{formatDate(transaction.purchaseDate)}
</DialogDescription>
<DialogHeader className="text-left">
<div className="flex min-w-0 items-start gap-2">
<EstablishmentLogo size={40} name={transaction.name} />
<div className="min-w-0">
<DialogTitle className="truncate">{transaction.name}</DialogTitle>
<DialogDescription className="mt-1">
{formatDate(transaction.purchaseDate)}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">

View File

@@ -1,7 +1,13 @@
"use client";
import { useState } from "react";
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
import { Label } from "@/shared/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import {
Select,
SelectContent,
@@ -14,6 +20,61 @@ import { cn } from "@/shared/utils/ui";
import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./transaction-dialog-types";
function InlineStartInstallmentPicker({
value,
options,
onChange,
}: {
value: string;
options: number[];
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const selected = Number(value || "1");
const selectedLabel =
!Number.isNaN(selected) && selected > 0
? `${selected}ª parcela`
: "1ª parcela";
const disabled = options.length === 0;
return (
<div className="ml-1">
<span className="text-xs text-muted-foreground">Começar em </span>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="cursor-pointer text-xs text-primary underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:no-underline"
disabled={disabled}
>
{selectedLabel}
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
<div className="max-h-56 overflow-y-auto">
{options.map((option) => (
<button
key={option}
type="button"
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground",
option === selected && "font-medium text-primary",
)}
onClick={() => {
onChange(String(option));
setOpen(false);
}}
>
{option}ª parcela
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
export function ConditionSection({
formState,
onFieldChange,
@@ -37,11 +98,17 @@ export function ConditionSection({
const installmentSummary =
showInstallments &&
formState.installmentCount &&
amount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? getInstallmentLabel(installmentCount)
: null;
const startInstallmentOptions =
showInstallments &&
formState.installmentCount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? Array.from({ length: installmentCount }, (_, index) => index + 1)
: [];
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
@@ -96,6 +163,11 @@ export function ConditionSection({
})}
</SelectContent>
</Select>
<InlineStartInstallmentPicker
value={formState.startInstallment}
options={startInstallmentOptions}
onChange={(value) => onFieldChange("startInstallment", value)}
/>
</div>
) : null}

View File

@@ -17,6 +17,7 @@ export interface TransactionDialogProps {
estabelecimentos: string[];
transaction?: TransactionItem;
defaultPeriod?: string;
defaultAccountId?: string | null;
defaultCardId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;

View File

@@ -1,6 +1,6 @@
"use client";
import { RiArrowDropDownLine } from "@remixicon/react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createTransactionAction,
@@ -65,6 +65,7 @@ export function TransactionDialog({
estabelecimentos,
transaction,
defaultPeriod,
defaultAccountId,
defaultCardId,
defaultPaymentMethod,
defaultPurchaseDate,
@@ -88,6 +89,7 @@ export function TransactionDialog({
const [formState, setFormState] = useState<FormState>(() =>
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
defaultAccountId,
defaultCardId,
defaultPaymentMethod,
defaultPurchaseDate,
@@ -102,6 +104,8 @@ export function TransactionDialog({
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
const [extrasOpen, setExtrasOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (dialogOpen) {
@@ -110,6 +114,7 @@ export function TransactionDialog({
defaultPayerId,
defaultPeriod,
{
defaultAccountId,
defaultCardId,
defaultPaymentMethod,
defaultPurchaseDate,
@@ -142,12 +147,14 @@ export function TransactionDialog({
setPendingFiles([]);
setPendingDetachIds([]);
setPendingUploadFiles([]);
setExtrasOpen(initial.condition !== "À vista");
}
}, [
dialogOpen,
transaction,
defaultPayerId,
defaultPeriod,
defaultAccountId,
defaultCardId,
defaultPaymentMethod,
defaultPurchaseDate,
@@ -211,6 +218,22 @@ export function TransactionDialog({
});
}
function handleExtrasOpenChange(nextOpen: boolean) {
setExtrasOpen(nextOpen);
if (nextOpen) {
requestAnimationFrame(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
});
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
@@ -308,6 +331,12 @@ export function TransactionDialog({
formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount)
: undefined,
startInstallment:
mode === "create" &&
formState.condition === "Parcelado" &&
formState.startInstallment
? Number(formState.startInstallment)
: undefined,
recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
@@ -527,18 +556,21 @@ export function TransactionDialog({
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="min-w-0 overflow-x-hidden">
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden p-4 sm:p-10">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
className="flex min-w-0 flex-col gap-0"
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
onSubmit={handleSubmit}
noValidate
>
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
<div
ref={scrollContainerRef}
className="-mx-1 min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-1 pb-1"
>
{/* Detalhes */}
<div className="space-y-3">
<BasicFieldsSection
@@ -634,7 +666,8 @@ export function TransactionDialog({
</>
) : (
<Collapsible
defaultOpen={formState.condition !== "À vista"}
open={extrasOpen}
onOpenChange={handleExtrasOpenChange}
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">
@@ -680,7 +713,7 @@ export function TransactionDialog({
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
) : null}
<DialogFooter className="mt-4">
<DialogFooter className="mt-4 shrink-0">
<Button
type="button"
variant="outline"

View File

@@ -74,16 +74,16 @@ export function GlobalFields({
return (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm">
Aplicado a todos os lançamentos importados.
Aplicado aos lançamentos selecionados.
</p>
<div className="flex flex-wrap gap-4">
<div className="flex min-w-44 flex-col gap-1.5">
<div className="grid w-full grid-cols-1 items-end justify-start gap-3 sm:grid-cols-[repeat(2,minmax(0,14rem))] lg:grid-cols-[16rem_14rem_18rem_14rem]">
<div className="flex min-w-0 flex-col gap-1.5">
<Label>Conta / Cartão</Label>
<Select
value={accountCardValue ?? ""}
onValueChange={(v) => onAccountCardChange(v || null)}
>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecionar conta ou cartão…" />
</SelectTrigger>
<SelectContent>
@@ -122,14 +122,14 @@ export function GlobalFields({
</Select>
</div>
<div className="flex min-w-44 flex-col gap-1.5">
<div className="flex min-w-0 flex-col gap-1.5">
<Label>Pessoa</Label>
<Select
value={payerId ?? ""}
onValueChange={(v) => onPayerChange(v || null)}
>
<SelectTrigger>
<SelectValue placeholder="Selecionar pessoa…" />
<SelectTrigger className="w-full">
<SelectValue placeholder="Aplicar pessoa…" />
</SelectTrigger>
<SelectContent>
{payerOptions.map((opt) => (
@@ -144,10 +144,10 @@ export function GlobalFields({
</Select>
</div>
<div className="flex min-w-44 flex-col gap-1.5">
<div className="flex min-w-0 flex-col gap-1.5">
<Label>Categoria</Label>
<Select onValueChange={onBulkCategoryChange}>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="Aplicar a todas selecionadas…" />
</SelectTrigger>
<SelectContent>
@@ -185,7 +185,7 @@ export function GlobalFields({
</div>
{isCard && (
<div className="flex min-w-44 flex-col gap-1.5">
<div className="flex min-w-0 flex-col gap-1.5">
<Label>Fatura</Label>
<PeriodPicker
value={invoicePeriod ?? ""}

View File

@@ -44,6 +44,11 @@ import {
import { Skeleton } from "@/shared/components/ui/skeleton";
import type { ImportStatement } from "@/shared/lib/import/types";
const categoryGroupByTransactionType = {
expense: "despesa",
income: "receita",
} as const;
interface ImportPageProps {
payerOptions: SelectOption[];
accountOptions: SelectOption[];
@@ -69,33 +74,63 @@ export function ImportPage({
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
const handleParsed = useCallback(async (stmt: ImportStatement) => {
setStatement(stmt);
setIsChecking(true);
const categoryGroupById = useMemo(
() =>
new Map(categoryOptions.map((option) => [option.value, option.group])),
[categoryOptions],
);
try {
const fitIds = stmt.transactions
.map((t) => t.externalId)
.filter((id): id is string => id !== null);
const isCategoryCompatible = useCallback(
(
categoryId: string | null,
transactionType: ReviewRow["transactionType"],
) =>
!categoryId ||
categoryGroupById.get(categoryId) ===
categoryGroupByTransactionType[transactionType],
[categoryGroupById],
);
const [duplicates, categoryMappings] = await Promise.all([
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
]);
const handleParsed = useCallback(
async (stmt: ImportStatement) => {
setStatement(stmt);
setIsChecking(true);
setRows(
stmt.transactions.map((t) => ({
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
categoryId:
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
})),
);
} finally {
setIsChecking(false);
}
}, []);
try {
const fitIds = stmt.transactions
.map((t) => t.externalId)
.filter((id): id is string => id !== null);
const [duplicates, categoryMappings] = await Promise.all([
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
]);
setRows(
stmt.transactions.map((t) => {
const mappedCategoryId =
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
return {
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
payerId,
categoryId: isCategoryCompatible(
mappedCategoryId,
t.transactionType,
)
? mappedCategoryId
: null,
};
}),
);
} finally {
setIsChecking(false);
}
},
[isCategoryCompatible, payerId],
);
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => {
@@ -121,7 +156,17 @@ export function ImportPage({
const handleCategoryChange = (index: number, categoryId: string | null) => {
setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, categoryId } : r)),
prev.map((r, i) =>
i === index && isCategoryCompatible(categoryId, r.transactionType)
? { ...r, categoryId }
: r,
),
);
};
const handlePayerChange = (index: number, payerId: string | null) => {
setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, payerId } : r)),
);
};
@@ -150,17 +195,36 @@ export function ImportPage({
};
const handleBulkCategoryChange = (categoryId: string) => {
setRows((prev) => prev.map((r) => (r.selected ? { ...r, categoryId } : r)));
setRows((prev) =>
prev.map((r) =>
r.selected && isCategoryCompatible(categoryId, r.transactionType)
? { ...r, categoryId }
: r,
),
);
};
const handleBulkPayerChange = (nextPayerId: string | null) => {
setPayerId(nextPayerId);
setRows((prev) =>
prev.map((r) => (r.selected ? { ...r, payerId: nextPayerId } : r)),
);
};
const isCard = accountCardValue?.startsWith("card:") ?? false;
const { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => {
const {
selectedRows,
duplicateCount,
uncategorizedCount,
withoutPayerCount,
} = useMemo(() => {
const selected = rows.filter((r) => r.selected);
return {
selectedRows: selected,
duplicateCount: rows.filter((r) => r.isDuplicate).length,
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
withoutPayerCount: selected.filter((r) => !r.payerId).length,
};
}, [rows]);
@@ -168,6 +232,7 @@ export function ImportPage({
selectedRows.length > 0 &&
!!accountCardValue &&
uncategorizedCount === 0 &&
withoutPayerCount === 0 &&
(!isCard || !!invoicePeriod) &&
!isPending;
@@ -191,6 +256,7 @@ export function ImportPage({
description: r.description,
transactionType: r.transactionType,
categoryId: r.categoryId,
payerId: r.payerId,
})),
payerId,
accountId,
@@ -280,6 +346,7 @@ export function ImportPage({
selected={selectedRows.length}
duplicates={duplicateCount}
uncategorized={uncategorizedCount}
withoutPayer={withoutPayerCount}
/>
<GlobalFields
@@ -291,23 +358,25 @@ export function ImportPage({
payerId={payerId}
invoicePeriod={invoicePeriod}
onAccountCardChange={setAccountCardValue}
onPayerChange={setPayerId}
onPayerChange={handleBulkPayerChange}
onInvoicePeriodChange={setInvoicePeriod}
onBulkCategoryChange={handleBulkCategoryChange}
/>
<ReviewTable
rows={rows}
payerOptions={payerOptions}
categoryOptions={categoryOptions}
onToggle={toggleRow}
onToggleAll={toggleAll}
onPayerChange={handlePayerChange}
onCategoryChange={handleCategoryChange}
onDescriptionChange={handleDescriptionChange}
onUndoDuplicate={handleUndoDuplicate}
/>
{/* Sticky footer */}
<div className="sticky bottom-0 -mx-6 border-t bg-background px-6 py-4">
<div className="sticky bottom-0 -mx-6 px-6">
<div className="flex items-center justify-between gap-4">
<Button
variant="outline"

View File

@@ -10,6 +10,7 @@ interface ImportSummaryProps {
selected: number;
duplicates: number;
uncategorized: number;
withoutPayer: number;
}
export function ImportSummary({
@@ -18,9 +19,10 @@ export function ImportSummary({
selected,
duplicates,
uncategorized,
withoutPayer,
}: ImportSummaryProps) {
return (
<Card className="flex flex-col gap-1 p-5 text-sm bg-linear-to-br from-primary/5 to-transparent">
<Card className="flex flex-col gap-1 p-5 text-sm bg-primary/10 shadow-none ">
{/* Linha 1: título */}
<div className="flex items-center gap-2">
<span className="font-medium">{statement.source}</span>
@@ -40,8 +42,7 @@ export function ImportSummary({
)}
<span>
<span className="font-medium text-foreground">{selected}</span>/
{total} selecionadas
{selected}/{total} selecionadas
</span>
{duplicates > 0 && (
@@ -59,6 +60,16 @@ export function ImportSummary({
</span>
)
)}
{withoutPayer > 0 ? (
<span>{withoutPayer} sem pessoa</span>
) : (
selected > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">
todas com pessoa
</span>
)
)}
</div>
</Card>
);

View File

@@ -2,7 +2,10 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { CategorySelectContent } from "@/features/transactions/components/select-items";
import {
CategorySelectContent,
PayerSelectContent,
} from "@/features/transactions/components/select-items";
import type { SelectOption } from "@/features/transactions/components/types";
import MoneyValues from "@/shared/components/money-values";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
@@ -31,17 +34,28 @@ import {
import type { ImportedTransaction } from "@/shared/lib/import/types";
import { formatDate } from "@/shared/utils/date";
const categoryGroupByTransactionType: Record<
ImportedTransaction["transactionType"],
string
> = {
expense: "despesa",
income: "receita",
};
export type ReviewRow = ImportedTransaction & {
selected: boolean;
isDuplicate: boolean;
categoryId: string | null;
payerId: string | null;
};
interface ReviewTableProps {
rows: ReviewRow[];
payerOptions: SelectOption[];
categoryOptions: SelectOption[];
onToggle: (index: number) => void;
onToggleAll: (selected: boolean) => void;
onPayerChange: (index: number, payerId: string | null) => void;
onCategoryChange: (index: number, categoryId: string | null) => void;
onDescriptionChange: (index: number, description: string) => void;
onUndoDuplicate: (index: number) => void;
@@ -49,9 +63,11 @@ interface ReviewTableProps {
export function ReviewTable({
rows,
payerOptions,
categoryOptions,
onToggle,
onToggleAll,
onPayerChange,
onCategoryChange,
onDescriptionChange,
onUndoDuplicate,
@@ -97,6 +113,7 @@ export function ReviewTable({
</TableHead>
<TableHead className="w-24">Data</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-44">Pessoa</TableHead>
<TableHead className="w-44">Categoria</TableHead>
<TableHead className="w-20">Tipo</TableHead>
<TableHead className="w-28 text-right">Valor</TableHead>
@@ -106,7 +123,7 @@ export function ReviewTable({
{paddingTop > 0 && (
<TableRow>
<TableCell
colSpan={6}
colSpan={7}
style={{ height: paddingTop, padding: 0 }}
/>
</TableRow>
@@ -117,6 +134,11 @@ export function ReviewTable({
return null;
}
const index = virtualRow.index;
const categoryOptionsForRow = categoryOptions.filter(
(option) =>
option.group ===
categoryGroupByTransactionType[row.transactionType],
);
return (
<TableRow
key={row.externalId ?? `${row.date}-${index}`}
@@ -177,6 +199,26 @@ export function ReviewTable({
</div>
)}
</TableCell>
<TableCell>
<Select
value={row.payerId ?? ""}
onValueChange={(v) => onPayerChange(index, v || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Pessoa…" />
</SelectTrigger>
<SelectContent>
{payerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<PayerSelectContent
label={opt.label}
avatarUrl={opt.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={row.categoryId ?? ""}
@@ -186,7 +228,7 @@ export function ReviewTable({
<SelectValue placeholder="Categoria…" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((opt) => (
{categoryOptionsForRow.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent
label={opt.label}
@@ -225,7 +267,7 @@ export function ReviewTable({
{paddingBottom > 0 && (
<TableRow>
<TableCell
colSpan={6}
colSpan={7}
style={{ height: paddingBottom, padding: 0 }}
/>
</TableRow>

View File

@@ -63,6 +63,7 @@ interface TransactionsPageProps {
categoryFilterOptions: TransactionFilterOption[];
accountCardFilterOptions: AccountCardFilterOption[];
selectedPeriod: string;
defaultAccountId?: string | null;
estabelecimentos: string[];
allowCreate?: boolean;
noteAsColumn?: boolean;
@@ -96,6 +97,7 @@ export function TransactionsPage({
categoryFilterOptions,
accountCardFilterOptions,
selectedPeriod,
defaultAccountId,
estabelecimentos,
allowCreate = true,
noteAsColumn = false,
@@ -562,6 +564,7 @@ export function TransactionsPage({
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
@@ -585,6 +588,7 @@ export function TransactionsPage({
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
@@ -648,6 +652,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
maxSizeMb={attachmentMaxSizeMb}
/>
@@ -669,6 +674,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos}
transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
isImporting={true}
maxSizeMb={attachmentMaxSizeMb}
/>
@@ -697,6 +703,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos}
transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
onBulkEditRequest={handleBulkEditRequest}
onSplitEditRequest={handleSplitEditRequest}
maxSizeMb={attachmentMaxSizeMb}
@@ -844,16 +851,6 @@ export function TransactionsPage({
onOpenChange={setAnticipationHistoryOpen}
seriesId={selectedForAnticipation.seriesId as string}
lancamentoName={selectedForAnticipation.name}
onViewLancamento={(transactionId) => {
const transaction = transactionList.find(
(l) => l.id === transactionId,
);
if (transaction) {
setSelectedTransaction(transaction);
setDetailsOpen(true);
setAnticipationHistoryOpen(false);
}
}}
/>
)}
</>

View File

@@ -1,21 +1,15 @@
"use client";
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
import { RiCalendarCheckLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useTransition } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
import type { ReactNode } from "react";
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
@@ -23,174 +17,129 @@ import { displayPeriod } from "@/shared/utils/period";
interface AnticipationCardProps {
anticipation: InstallmentAnticipationListItem;
onViewLancamento?: (transactionId: string) => void;
onCanceled?: () => void;
}
export function AnticipationCard({
anticipation,
onViewLancamento,
onCanceled,
}: AnticipationCardProps) {
const [isPending, startTransition] = useTransition();
export function AnticipationCard({ anticipation }: AnticipationCardProps) {
const isSettled = anticipation.transaction?.isSettled === true;
const canCancel = !isSettled;
const totalAmount = Number(anticipation.totalAmount);
const discount = Number(anticipation.discount);
const finalAmount =
totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
const hasDiscount = discount > 0;
const formatDate = (date: string) => {
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
};
const handleCancel = async () => {
startTransition(async () => {
const result = await cancelInstallmentAnticipationAction({
anticipationId: anticipation.id,
});
if (result.success) {
toast.success(result.message);
onCanceled?.();
} else {
toast.error(result.error || "Erro ao cancelar antecipação");
}
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", {
locale: ptBR,
});
};
const handleViewLancamento = () => {
onViewLancamento?.(anticipation.transactionId);
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-base">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<CardDescription>
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
{formatDate(anticipation.anticipationDate)}
</CardDescription>
<Card className="shadow-none py-2">
<CardHeader className="space-y-3 p-4 pb-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<CardTitle className="text-base leading-none">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RiCalendarCheckLine className="size-3 shrink-0" />
<span>{formatDate(anticipation.anticipationDate)}</span>
</div>
</div>
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg bg-primary/10 p-3">
<span className="text-xs font-medium text-foreground">
{hasDiscount ? "Valor Final" : "Valor Total"}
</span>
<span className="text-lg font-semibold leading-none text-primary">
<MoneyValues amount={finalAmount} />
</span>
</div>
<Badge variant="secondary">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">Valor Original</dt>
<dd className="mt-1 font-medium">
<MoneyValues amount={Number(anticipation.totalAmount)} />
</dd>
</div>
<CardContent className="px-4 pb-4 pt-0">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<DetailItem label="Valor Original">
<MoneyValues amount={totalAmount} />
</DetailItem>
{Number(anticipation.discount) > 0 && (
<div>
<dt className="text-muted-foreground">Desconto</dt>
<dd className="mt-1 font-medium text-success">
- <MoneyValues amount={Number(anticipation.discount)} />
</dd>
</div>
{hasDiscount ? (
<DetailItem label="Desconto" valueClassName="text-success">
- <MoneyValues amount={discount} />
</DetailItem>
) : (
<div />
)}
<div
className={
Number(anticipation.discount) > 0
? "col-span-2 border-t pt-3"
: ""
}
>
<dt className="text-muted-foreground">
{Number(anticipation.discount) > 0
? "Valor Final"
: "Valor Total"}
</dt>
<dd className="mt-1 text-lg font-semibold text-primary">
<MoneyValues
amount={
Number(anticipation.totalAmount) < 0
? Number(anticipation.totalAmount) +
Number(anticipation.discount)
: Number(anticipation.totalAmount) -
Number(anticipation.discount)
}
/>
</dd>
</div>
<DetailItem label="Status">
<Badge
variant={isSettled ? "success" : "outline"}
className="h-5 rounded-full px-2 text-xs"
>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</DetailItem>
<div>
<dt className="text-muted-foreground">Status do Lançamento</dt>
<dd className="mt-1">
<Badge variant={isSettled ? "success" : "outline"}>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</dd>
</div>
{anticipation.payer && (
<div>
<dt className="text-muted-foreground">Pessoa</dt>
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
</div>
{anticipation.payer ? (
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
) : (
<div />
)}
{anticipation.category && (
<div>
<dt className="text-muted-foreground">Categoria</dt>
<dd className="mt-1 font-medium">{anticipation.category.name}</dd>
</div>
)}
{anticipation.category ? (
<DetailItem label="Categoria">
{anticipation.category.name}
</DetailItem>
) : null}
</dl>
{anticipation.note && (
<div className="rounded-lg border p-3">
<dt className="text-xs font-medium text-muted-foreground">
{anticipation.note ? (
<div className="mt-3 border-t pt-3">
<p className="text-xs font-medium text-muted-foreground">
Observação
</dt>
<dd className="mt-1 text-sm">{anticipation.note}</dd>
</p>
<p className="mt-1 text-sm leading-snug">{anticipation.note}</p>
</div>
)}
) : null}
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={handleViewLancamento}
disabled={isPending}
>
<RiEyeLine className="mr-2 size-4" />
Ver Lançamento
</Button>
{canCancel && (
<ConfirmActionDialog
trigger={
<Button variant="destructive" size="sm" disabled={isPending}>
<RiCloseLine className="mr-2 size-4" />
Cancelar Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancel}
/>
)}
{isSettled && (
<div className="text-xs text-muted-foreground">
Não é possível cancelar uma antecipação paga
</div>
)}
</CardFooter>
</Card>
);
}
function DetailItem({
label,
children,
valueClassName,
}: {
label: string;
children: ReactNode;
valueClassName?: string;
}) {
return (
<div className="min-w-0 space-y-1">
<dt className="text-xs font-medium leading-none text-muted-foreground">
{label}
</dt>
<dd
className={`truncate text-sm font-medium leading-tight ${
valueClassName ?? ""
}`}
>
{children}
</dd>
</div>
);
}

View File

@@ -426,7 +426,7 @@ function buildColumns({
const initial = displayName.charAt(0).toUpperCase() || "?";
const content = (
<>
<Avatar className="size-7">
<Avatar className="size-8">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-xs font-medium uppercase">
{initial}
@@ -477,15 +477,21 @@ function buildColumns({
const content = (
<span className="inline-flex items-center gap-2">
{logoSrc && (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={30}
height={30}
className="rounded-full"
/>
<Avatar className="size-8">
<AvatarImage src={logoSrc} alt={`Logo de ${label}`} />
<AvatarFallback className="text-xs font-medium uppercase">
{label}
</AvatarFallback>
</Avatar>
)}
<span className="truncate">{label}</span>
<span
className={cn(
"truncate underline-offset-2",
isOwnData && href && "group-hover:underline",
)}
>
{label}
</span>
</span>
);
@@ -503,7 +509,7 @@ function buildColumns({
return (
<Tooltip>
<TooltipTrigger asChild>
<Link href={href} className="hover:underline">
<Link href={href} className="group">
{content}
</Link>
</TooltipTrigger>
@@ -654,14 +660,14 @@ function buildColumns({
Editar
</DropdownMenuItem>
)}
{row.original.categoriaName !== "Pagamentos" &&
{!row.original.readonly &&
row.original.userId === currentUserId && (
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
<RiFileCopyLine className="size-4" />
Copiar
</DropdownMenuItem>
)}
{row.original.categoriaName !== "Pagamentos" &&
{!row.original.readonly &&
row.original.userId !== currentUserId && (
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
<RiFileCopyLine className="size-4" />

View File

@@ -23,12 +23,17 @@ import {
import {
AMOUNT_MAX_PARAM,
AMOUNT_MIN_PARAM,
DATE_END_PARAM,
DATE_START_PARAM,
PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
import {
parseDateFilterParam,
parsePositiveAmount,
} from "@/features/transactions/lib/page-helpers";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
@@ -39,6 +44,7 @@ import {
CommandItem,
CommandList,
} from "@/shared/components/ui/command";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Drawer,
DrawerContent,
@@ -60,7 +66,12 @@ import {
SelectItem,
SelectTrigger,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
import { slugify } from "@/shared/utils/string";
import { cn } from "@/shared/utils/ui";
import {
@@ -83,6 +94,9 @@ const normalizeAmountParam = (raw: string): string | null => {
return parsed === null ? null : parsed.toString();
};
const normalizeDateParam = (raw: string): string | null =>
parseDateFilterParam(raw.trim());
function useDebouncedAmountFilter(
param: string,
searchParams: URLSearchParams | ReadonlyURLSearchParams,
@@ -135,6 +149,7 @@ function FilterSelect({
value === FILTER_EMPTY_VALUE
? placeholder
: (current?.label ?? placeholder);
const hasSelection = value !== FILTER_EMPTY_VALUE && Boolean(current);
return (
<Select
@@ -148,8 +163,13 @@ function FilterSelect({
className={cn("text-sm border-dashed", widthClass)}
disabled={disabled}
>
<span className="truncate">
{value !== FILTER_EMPTY_VALUE && current && renderContent
<span
className={cn(
"truncate",
hasSelection ? "text-foreground" : "text-muted-foreground",
)}
>
{current && renderContent
? renderContent(current.label)
: displayLabel}
</span>
@@ -255,12 +275,19 @@ function MultiSelectFilter({
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm border-dashed font-normal",
"justify-between text-sm border-dashed font-normal shadow-none",
widthClass,
)}
disabled={disabled}
>
<span className="truncate flex items-center gap-2">
<span
className={cn(
"truncate flex items-center gap-2",
selectedOptions.length > 0
? "text-foreground"
: "text-muted-foreground",
)}
>
{triggerLabel}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
@@ -392,6 +419,13 @@ export function TransactionsFilters({
[searchParams, pathname, router],
);
const handleDateFilterChange = useCallback(
(key: string, value: string) => {
handleFilterChange(key, normalizeDateParam(value));
},
[handleFilterChange],
);
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? "";
@@ -509,25 +543,46 @@ export function TransactionsFilters({
);
const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters =
searchParams.get("type") ||
searchParams.getAll("condition").length > 0 ||
searchParams.getAll("payment").length > 0 ||
searchParams.getAll("payer").length > 0 ||
searchParams.getAll("category").length > 0 ||
searchParams.getAll("accountCard").length > 0 ||
searchParams.get("settled") ||
searchParams.get("hasAttachment") ||
searchParams.get("isDivided") ||
searchParams.get(AMOUNT_MIN_PARAM) ||
searchParams.get(AMOUNT_MAX_PARAM);
const hasDateRangeFilter =
Boolean(searchParams.get(DATE_START_PARAM)) ||
Boolean(searchParams.get(DATE_END_PARAM));
const hasAmountFilter =
Boolean(searchParams.get(AMOUNT_MIN_PARAM)) ||
Boolean(searchParams.get(AMOUNT_MAX_PARAM));
const activeFilterCount = [
Boolean(searchParams.get("type")),
searchParams.getAll("condition").length > 0,
searchParams.getAll("payment").length > 0,
searchParams.getAll("payer").length > 0,
searchParams.getAll("category").length > 0,
searchParams.getAll("accountCard").length > 0,
Boolean(searchParams.get("settled")),
Boolean(searchParams.get("hasAttachment")),
Boolean(searchParams.get("isDivided")),
hasAmountFilter,
hasDateRangeFilter,
].filter(Boolean).length;
const hasActiveFilters = activeFilterCount > 0;
const settledFilterValue = searchParams.get("settled") ?? FILTER_EMPTY_VALUE;
const handleResetFilters = () => {
handleReset();
setDrawerOpen(false);
};
const handleResetDateRange = () => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete(DATE_START_PARAM);
nextParams.delete(DATE_END_PARAM);
nextParams.delete("page");
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
return (
<div
className={cn(
@@ -607,182 +662,234 @@ export function TransactionsFilters({
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Tipo de Lançamento
</label>
<FilterSelect
param="type"
placeholder="Todos"
options={TRANSACTION_TYPES.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<TransactionTypeSelectContent label={label} />
)}
/>
</div>
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Tipo de lançamento
</label>
<FilterSelect
param="type"
placeholder="Todos"
options={TRANSACTION_TYPES.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<TransactionTypeSelectContent label={label} />
)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Condição de Lançamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={conditionOptions}
selected={getParamValues("condition")}
onChange={(values) =>
handleMultiFilterChange("condition", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Condição de pagamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={conditionOptions}
selected={getParamValues("condition")}
onChange={(values) =>
handleMultiFilterChange("condition", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Forma de Pagamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={paymentOptions}
selected={getParamValues("payment")}
onChange={(values) =>
handleMultiFilterChange("payment", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Forma de pagamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={paymentOptions}
selected={getParamValues("payment")}
onChange={(values) =>
handleMultiFilterChange("payment", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pessoa</label>
<MultiSelectFilter
placeholder="Todas"
options={payerMultiOptions}
selected={getParamValues("payer")}
onChange={(values) =>
handleMultiFilterChange("payer", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar pessoa..."
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Pessoa
</label>
<MultiSelectFilter
placeholder="Todas"
options={payerMultiOptions}
selected={getParamValues("payer")}
onChange={(values) =>
handleMultiFilterChange("payer", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar pessoa..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Categoria</label>
<MultiSelectFilter
placeholder="Todas"
options={categoryMultiOptions}
selected={getParamValues("category")}
onChange={(values) =>
handleMultiFilterChange("category", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar categoria..."
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Categoria
</label>
<MultiSelectFilter
placeholder="Todas"
options={categoryMultiOptions}
selected={getParamValues("category")}
onChange={(values) =>
handleMultiFilterChange("category", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar categoria..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Conta/Cartão</label>
<MultiSelectFilter
placeholder="Todos"
options={accountCardMultiOptions}
selected={getParamValues("accountCard")}
onChange={(values) =>
handleMultiFilterChange("accountCard", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar conta ou cartão..."
groupOrder={["Contas", "Cartões"]}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Faixa de valor</label>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Mínimo"
aria-label="Valor mínimo"
value={valorMinValue}
onChange={(event) => setValorMinValue(event.target.value)}
disabled={isPending}
className="text-sm border-dashed"
/>
<span className="text-xs text-muted-foreground">até</span>
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Máximo"
aria-label="Valor máximo"
value={valorMaxValue}
onChange={(event) => setValorMaxValue(event.target.value)}
disabled={isPending}
className="text-sm border-dashed"
/>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Conta/Cartão
</label>
<MultiSelectFilter
placeholder="Todos"
options={accountCardMultiOptions}
selected={getParamValues("accountCard")}
onChange={(values) =>
handleMultiFilterChange("accountCard", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar conta ou cartão..."
groupOrder={["Contas", "Cartões"]}
/>
</div>
</div>
</div>
<Separator />
<div className="space-y-3">
<p className="text-sm font-medium">Status</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label
htmlFor="filter-pago"
className="text-sm text-muted-foreground cursor-pointer"
>
Somente pagos
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
Período
</label>
<Switch
id="filter-pago"
checked={
searchParams.get("settled") ===
SETTLED_FILTER_VALUES.PAID
}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"settled",
checked ? SETTLED_FILTER_VALUES.PAID : null,
);
}}
/>
{hasDateRangeFilter ? (
<button
type="button"
onClick={handleResetDateRange}
disabled={isPending}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
>
Limpar período
</button>
) : null}
</div>
<div className="flex items-center justify-between">
<label
htmlFor="filter-nao-pago"
className="text-sm text-muted-foreground cursor-pointer"
>
Somente não pagos
</label>
<Switch
id="filter-nao-pago"
checked={
searchParams.get("settled") ===
SETTLED_FILTER_VALUES.UNPAID
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
<DatePicker
value={searchParams.get(DATE_START_PARAM) ?? ""}
onChange={(value) =>
handleDateFilterChange(DATE_START_PARAM, value)
}
placeholder="Data inicial"
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"settled",
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
);
}}
inputClassName="border-dashed"
compact
/>
<span className="hidden text-xs text-muted-foreground sm:block">
até
</span>
<DatePicker
value={searchParams.get(DATE_END_PARAM) ?? ""}
onChange={(value) =>
handleDateFilterChange(DATE_END_PARAM, value)
}
placeholder="Data final"
disabled={isPending}
inputClassName="border-dashed"
compact
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Faixa de valor
</label>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Mínimo"
aria-label="Valor mínimo"
value={valorMinValue}
onChange={(event) =>
setValorMinValue(event.target.value)
}
disabled={isPending}
className="text-sm border-dashed"
/>
<span className="text-xs text-muted-foreground">até</span>
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Máximo"
aria-label="Valor máximo"
value={valorMaxValue}
onChange={(event) =>
setValorMaxValue(event.target.value)
}
disabled={isPending}
className="text-sm border-dashed"
/>
</div>
</div>
</div>
<Separator />
<div className="space-y-3">
<ToggleGroup
type="single"
value={settledFilterValue}
onValueChange={(value) => {
if (!value) return;
handleFilterChange(
"settled",
value === FILTER_EMPTY_VALUE ? null : value,
);
}}
variant="outline"
size="sm"
className="grid w-full grid-cols-3 rounded-md bg-muted/30 p-0.5"
aria-label="Status de pagamento"
>
<ToggleGroupItem
value={FILTER_EMPTY_VALUE}
className="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"
>
Todos
</ToggleGroupItem>
<ToggleGroupItem
value={SETTLED_FILTER_VALUES.PAID}
className="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"
>
Pagos
</ToggleGroupItem>
<ToggleGroupItem
value={SETTLED_FILTER_VALUES.UNPAID}
className="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"
>
Não pagos
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex items-center justify-between">
@@ -824,14 +931,27 @@ export function TransactionsFilters({
</div>
<DrawerFooter>
<Button
type="button"
variant="outline"
onClick={handleResetFilters}
disabled={isPending || !hasActiveFilters}
>
Limpar filtros
</Button>
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
<span className="text-xs text-muted-foreground">
{hasActiveFilters
? `${activeFilterCount} ${
activeFilterCount === 1
? "filtro ativo"
: "filtros ativos"
}`
: "Nenhum filtro ativo"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetFilters}
disabled={isPending || !hasActiveFilters}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>

View File

@@ -174,7 +174,7 @@ export function TransactionsTable({
: getPaginationRowModel(),
manualPagination: isServerPaginated,
pageCount: serverPagination?.totalPages,
enableRowSelection: true,
enableRowSelection: (row) => !row.original.readonly,
});
const rowModel = table.getRowModel();

View File

@@ -43,15 +43,34 @@ const loadPdfDeps = async () => {
return { jsPDF, autoTable };
};
const formatPeriodDate = (dateString: string) =>
formatDateOnly(dateString, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? dateString;
export function TransactionsExport({
lancamentos,
period,
exportContext,
}: TransactionsExportProps) {
const [isExporting, setIsExporting] = useState(false);
const dateStartFilter = exportContext?.filters.dateStartFilter ?? null;
const dateEndFilter = exportContext?.filters.dateEndFilter ?? null;
const periodLabel =
dateStartFilter || dateEndFilter
? `${dateStartFilter ? formatPeriodDate(dateStartFilter) : "Início"} até ${
dateEndFilter ? formatPeriodDate(dateEndFilter) : "hoje"
}`
: displayPeriod(period);
const filePeriodSlug =
dateStartFilter || dateEndFilter
? `${dateStartFilter ?? "inicio"}-${dateEndFilter ?? "hoje"}`
: period;
const getFileName = (extension: string) => {
return `lancamentos-${period}.${extension}`;
return `lancamentos-${filePeriodSlug}.${extension}`;
};
const formatDate = (dateString: string) => {
@@ -251,7 +270,7 @@ export function TransactionsExport({
doc.text("Lançamentos", titleX, 15);
doc.setFontSize(10);
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
doc.text(`Período: ${periodLabel}`, titleX, 22);
doc.text(
`Gerado em: ${
formatDateTime(new Date(), {

View File

@@ -33,3 +33,5 @@ export const SETTLED_FILTER_VALUES = {
export const AMOUNT_MIN_PARAM = "valorMin";
export const AMOUNT_MAX_PARAM = "valorMax";
export const DATE_START_PARAM = "dataInicio";
export const DATE_END_PARAM = "dataFim";

View File

@@ -11,6 +11,8 @@ type TransactionExportFilters = {
dividedFilter: string | null;
amountMinFilter: number | null;
amountMaxFilter: number | null;
dateStartFilter: string | null;
dateEndFilter: string | null;
};
export type TransactionsExportContext = {

View File

@@ -80,6 +80,7 @@ export type TransactionFormState = {
cardId: string | undefined;
categoryId: string | undefined;
installmentCount: string;
startInstallment: string;
recurrenceCount: string;
dueDate: string;
boletoPaymentDate: string;
@@ -92,6 +93,7 @@ export type TransactionFormState = {
*/
type TransactionFormOverrides = {
defaultCardId?: string | null;
defaultAccountId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;
defaultName?: string | null;
@@ -178,7 +180,9 @@ export function buildTransactionInitialState(
? undefined
: isImporting
? undefined
: (transaction?.accountId ?? undefined),
: (transaction?.accountId ??
overrides?.defaultAccountId ??
undefined),
cardId:
paymentMethod === "Cartão de crédito"
? isImporting
@@ -191,6 +195,12 @@ export function buildTransactionInitialState(
installmentCount: transaction?.installmentCount
? String(transaction.installmentCount)
: "",
startInstallment:
isImporting &&
transaction?.condition === "Parcelado" &&
transaction.currentInstallment
? String(transaction.currentInstallment)
: "1",
recurrenceCount: transaction?.recurrenceCount
? String(transaction.recurrenceCount)
: "",
@@ -252,12 +262,25 @@ export function applyFieldDependencies(
if (key === "condition" && typeof value === "string") {
if (value !== "Parcelado") {
updates.installmentCount = "";
updates.startInstallment = "1";
}
if (value !== "Recorrente") {
updates.recurrenceCount = "";
}
}
if (key === "installmentCount" && typeof value === "string" && value) {
const nextCount = Number.parseInt(value, 10);
const currentStart = Number.parseInt(currentState.startInstallment, 10);
if (
!Number.isNaN(nextCount) &&
!Number.isNaN(currentStart) &&
currentStart > nextCount
) {
updates.startInstallment = String(nextCount);
}
}
// When payment method changes, adjust related fields
if (key === "paymentMethod" && typeof value === "string") {
if (value === "Cartão de crédito") {

View File

@@ -22,17 +22,25 @@ import type { SelectOption } from "@/features/transactions/components/types";
import {
AMOUNT_MAX_PARAM,
AMOUNT_MIN_PARAM,
DATE_END_PARAM,
DATE_START_PARAM,
PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/shared/lib/accounts/constants";
import {
PAYER_ROLE_ADMIN,
PAYER_ROLE_THIRD_PARTY,
} from "@/shared/lib/payers/constants";
import { toDateOnlyString } from "@/shared/utils/date";
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
import { slugify } from "@/shared/utils/string";
type PayerRow = typeof payers.$inferSelect;
@@ -60,6 +68,8 @@ export type TransactionSearchFilters = {
dividedFilter: string | null;
amountMinFilter: number | null;
amountMaxFilter: number | null;
dateStartFilter: string | null;
dateEndFilter: string | null;
};
type BaseSluggedOption = {
@@ -156,6 +166,14 @@ export const parsePositiveAmount = (value: string | null): number | null => {
return Math.round(normalized * 100) / 100;
};
export const parseDateFilterParam = (value: string | null): string | null => {
if (!value) return null;
const normalized = value.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return null;
const parsed = parseLocalDateString(normalized);
return Number.isNaN(parsed.getTime()) ? null : normalized;
};
export const extractTransactionSearchFilters = (
params: ResolvedSearchParams,
): TransactionSearchFilters => ({
@@ -175,6 +193,10 @@ export const extractTransactionSearchFilters = (
amountMaxFilter: parsePositiveAmount(
getSingleParam(params, AMOUNT_MAX_PARAM),
),
dateStartFilter: parseDateFilterParam(
getSingleParam(params, DATE_START_PARAM),
),
dateEndFilter: parseDateFilterParam(getSingleParam(params, DATE_END_PARAM)),
});
export const resolveTransactionPagination = (
@@ -371,10 +393,29 @@ export const buildTransactionWhere = ({
accountId?: string;
payerId?: string;
}): SQL[] => {
const where: SQL[] = [
eq(transactions.userId, userId),
eq(transactions.period, period),
];
const where: SQL[] = [eq(transactions.userId, userId)];
if (filters.dateStartFilter || filters.dateEndFilter) {
if (filters.dateStartFilter) {
where.push(
gte(
transactions.purchaseDate,
parseLocalDateString(filters.dateStartFilter),
),
);
}
if (filters.dateEndFilter) {
where.push(
lte(
transactions.purchaseDate,
parseLocalDateString(filters.dateEndFilter),
),
);
}
} else {
where.push(eq(transactions.period, period));
}
if (payerId) {
where.push(eq(transactions.payerId, payerId));
@@ -551,8 +592,10 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
hasAttachments: item.hasAttachments ?? false,
readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.category?.name === "Saldo inicial" ||
item.category?.name === "Pagamentos",
(item.note === INITIAL_BALANCE_NOTE &&
item.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE &&
item.condition === INITIAL_BALANCE_CONDITION &&
item.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD),
}));
const sortByLabel = <T extends { label: string }>(items: T[]) =>

View File

@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/shared/lib/auth/config";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
// Rotas protegidas que requerem autenticação
const PROTECTED_ROUTES = [
@@ -85,6 +86,22 @@ export default async function proxy(request: NextRequest) {
});
const isAuthenticated = !!session?.user;
const signupDisabled = isSignupDisabled();
if (signupDisabled) {
if (pathname === "/signup" || pathname.startsWith("/signup/")) {
return NextResponse.redirect(
new URL(isAuthenticated ? "/dashboard" : "/login", request.url),
);
}
if (pathname.startsWith("/api/auth/sign-up")) {
return NextResponse.json(
{ error: "Novos cadastros estão desativados." },
{ status: 403 },
);
}
}
// Redirect authenticated users away from login/signup pages
if (isAuthenticated && PUBLIC_AUTH_ROUTES.includes(pathname)) {

View File

@@ -10,7 +10,11 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { deriveNameFromLogo, resolveLogoSrc } from "@/shared/lib/logo";
import {
getLogoDisplayName,
normalizeForSearch,
resolveLogoSrc,
} from "@/shared/lib/logo";
import { cn } from "@/shared/utils/ui";
const DEFAULT_BASE_PATH = "/logos";
@@ -35,7 +39,7 @@ export function LogoPickerTrigger({
className,
}: LogoPickerTriggerProps) {
const hasLogo = Boolean(selectedLogo);
const selectedLogoLabel = deriveNameFromLogo(selectedLogo);
const selectedLogoLabel = getLogoDisplayName(selectedLogo);
const selectedLogoPath =
hasLogo && selectedLogo ? resolveLogoSrc(selectedLogo, { basePath }) : null;
@@ -102,8 +106,8 @@ export function LogoPickerDialog({
const filteredLogos = logos.filter((logo) => {
if (!search.trim()) return true;
const logoLabel = deriveNameFromLogo(logo).toLowerCase();
return logoLabel.includes(search.toLowerCase().trim());
const logoLabel = getLogoDisplayName(logo);
return normalizeForSearch(logoLabel).includes(normalizeForSearch(search));
});
const handleOpenChange = (isOpen: boolean) => {
@@ -145,7 +149,7 @@ export function LogoPickerDialog({
<div className="grid max-h-custom-height-card grid-cols-4 gap-2 overflow-y-auto p-1 md:grid-cols-5">
{filteredLogos.map((logo) => {
const isActive = value === logo;
const logoLabel = deriveNameFromLogo(logo);
const logoLabel = getLogoDisplayName(logo);
return (
<button

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { deriveNameFromLogo } from "@/shared/lib/logo";
import { getLogoDisplayName } from "@/shared/lib/logo";
interface UseLogoSelectionProps {
mode: "create" | "update";
@@ -37,8 +37,8 @@ export function useLogoSelection({
}: UseLogoSelectionProps) {
const handleLogoSelection = useCallback(
(newLogo: string) => {
const derived = deriveNameFromLogo(newLogo);
const previousDerived = deriveNameFromLogo(currentLogo);
const derived = getLogoDisplayName(newLogo);
const previousDerived = getLogoDisplayName(currentLogo);
const shouldUpdateName =
mode === "create" ||

View File

@@ -36,9 +36,6 @@ export function NotificationBellTrigger({
"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 dark:hover:border-white/20 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-white/20",
"data-[state=open]:bg-black/10 data-[state=open]:text-black dark:data-[state=open]:bg-white/10 dark:data-[state=open]:text-white",
hasAnySourceItems
? "text-black dark:text-white"
: "text-black/75 dark:text-white/75",
)}
>
<RiNotification2Line
@@ -55,7 +52,7 @@ export function NotificationBellTrigger({
>
{displayCount}
</span>
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 [animation-iteration-count:3]" />
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 repeat-3" />
</>
) : null}
</button>

View File

@@ -87,7 +87,7 @@ function Calendar({
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-4 border border-transparent shadow-sm dark:border-border py-6 rounded-lg hover:border-primary/50 transition-colors duration-200",
"bg-card text-card-foreground flex flex-col gap-4 border border-transparent shadow-xs dark:border-border py-6 rounded-lg hover:border-primary/50 transition-colors duration-200",
className,
)}
{...props}

View File

@@ -75,6 +75,7 @@ export interface DatePickerProps {
required?: boolean;
disabled?: boolean;
className?: string;
inputClassName?: string;
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
compact?: boolean;
}
@@ -87,6 +88,7 @@ export function DatePicker({
required = false,
disabled = false,
className,
inputClassName,
compact = false,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false);
@@ -140,7 +142,7 @@ export function DatePicker({
id={id}
value={displayValue}
placeholder={placeholder}
className="bg-background pr-10"
className={cn("bg-background pr-10", inputClassName)}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
required={required}
@@ -172,8 +174,8 @@ export function DatePicker({
month={month}
onMonthChange={setMonth}
onSelect={handleCalendarSelect}
fromYear={2020}
toYear={new Date().getFullYear() + 10}
startMonth={new Date(2020, 0)}
endMonth={new Date(new Date().getFullYear() + 10, 11)}
locale={ptBR}
/>
</PopoverContent>

View File

@@ -17,7 +17,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
"bg-border/50 dark:bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}

View File

@@ -1,7 +1,8 @@
import { passkey } from "@better-auth/passkey";
import { betterAuth } from "better-auth";
import { APIError, betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
import { seedDefaultCategoriesForUser } from "@/shared/lib/categories/defaults";
import { db, schema } from "@/shared/lib/db";
import { ensureDefaultPayerForUser } from "@/shared/lib/payers/defaults";
@@ -122,6 +123,13 @@ export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async () => {
if (!isSignupDisabled()) return;
throw new APIError("FORBIDDEN", {
message: "Novos cadastros estão desativados.",
});
},
/**
* Após criar novo usuário, inicializa:
* 1. Categorias padrão (Receitas/Despesas)

View File

@@ -0,0 +1,4 @@
export function isSignupDisabled(): boolean {
const value = process.env.DISABLE_SIGNUP?.trim().toLowerCase();
return value === "true";
}

View File

@@ -14,12 +14,12 @@ function excelSerialToDate(
if (serial < 1) return null;
let adjusted = serial;
if (serial > 60) adjusted--;
const baseDate = new Date(1899, 11, 31);
const date = new Date(baseDate.getTime() + adjusted * 86400000);
const baseDate = Date.UTC(1899, 11, 31);
const date = new Date(baseDate + adjusted * 86400000);
return {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
y: date.getUTCFullYear(),
m: date.getUTCMonth() + 1,
d: date.getUTCDate(),
};
}
@@ -38,9 +38,9 @@ function parseDateValue(value: unknown): string | null {
// ExcelJS pode retornar Date objects
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
const y = value.getUTCFullYear();
const m = String(value.getUTCMonth() + 1).padStart(2, "0");
const d = String(value.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}

View File

@@ -0,0 +1,433 @@
/**
* Dicionário de nomes de exibição para logos.
* Usa o nome do arquivo (lowercase) como chave.
* Fallback: deriveNameFromLogo
*/
export const logoDisplayNames: Record<string, string> = {
"99pay.png": "99Pay",
"abank.png": "Akbank",
"abcbrasil.png": "ABC Brasil",
"abnamro.png": "ABN AMRO",
"abrapetite.png": "Abrapetite",
"absolutbank.png": "Absolut Bank",
"acessobank.png": "Acesso Bank",
"activo.bank.png": "ActivoBank",
"activtrades.png": "ActivTrades",
"agbank.png": "Agricultural Bank of China",
"agibank.png": "AgiBank",
"agora.png": "Ágora Investimentos",
"agribank.png": "BIDV",
"aktia.png": "Aktia Bank",
"alelo.png": "Alelo",
"alfabank.png": "Alfa-Bank",
"alphabank.png": "Alpha Bank",
"alt.bank.png": "alt.bank",
"amazon.png": "Amazon",
"amazonia.png": "Banco da Amazônia",
"ame.png": "Amex",
"ameriprisefinancial.png": "Ameriprise Financial",
"amex.png": "Amex",
"amppersonalbanking.png": "AMP",
"anz.png": "ANZ",
"asaas.png": "Asaas",
"asn.png": "ASN Bank",
"astro-pay.png": "AstroPay",
"atlantico.png": "ATLANTICO",
"atticabank.png": "Attica Bank",
"aura.png": "Aura",
"avenida.png": "Lojas Avenida",
"avenuesecuritie.png": "Avenue",
"azul.png": "Azul",
"b3.png": "B3",
"bahamas-cred.png": "Bahamas Cred",
"bancamediolanum.png": "Banca Mediolanum",
"bancamps.png": "Banca Monte dei Paschi di Siena",
"bancaopopularedisondrio.png": "Banca Populare di Sondrio",
"banco-bai.png": "Banco Bai",
"banco-bic.png": "Banco BIC",
"bancobpm.png": "Banco BPM",
"banco-comercio-industria.png": "Banco de Comércio e Indústria",
"bancodavivienda.png": "Davivienda",
"bancodebogota.png": "Banco de Bogotá",
"bancodelbajio.png": "BanBajío",
"bancodeoccident.png": "Banco de Occident",
"bancohipotecario.png": "Banco Hipotecario",
"bancolombia.png": "Bancolombia",
"bancomacro.png": "Banco Macro",
"banconacion.png": "Banco Nación",
"banco-parana.png": "Paraná Banco",
"bancopatagonia.png": "Banco Patagonia",
"bancoposta.png": "BancoPosta",
"banco-poupanca-credito.png": "Banco de Poupança e Crédito",
"banese.png": "Banco Banese",
"banestes.png": "Banestes",
"bangkokbank.png": "Bangkok Bank",
"banif.png": "Banif",
"bankia.png": "Bankia",
"bankmandiri.png": "Bank Mandiri",
"banknorwegian.png": "Bank Norwegian",
"bankofaland.png": "Bank of Åland",
"bankofamerica.png": "Bank of America",
"bankofchina.png": "Bank of China",
"bankrakyat.png": "Bank Rakyat",
"bankwest.png": "Bankwest",
"banorte.png": "Banorte",
"banpara.png": "Banpará",
"banrisul.png": "Banrisul",
"bari.png": "Banco Bari",
"bb.png": "Banco do Brasil",
"bbva.png": "BBVA",
"bca.png": "BCA",
"bemol.png": "Bemol",
"bendigobank.png": "Bendigo Bank",
"benvisavale.png": "Ben Visa Vale",
"betfair.png": "Betfair",
"bidv.png": "BIDV",
"bilhete-unico.png": "Bilhete Único",
"binance.png": "Binance",
"binomo.png": "Binomo",
"bipa.png": "Bipa",
"bitybank.png": "BityBank",
"bitz.png": "Bitz",
"bmg.png": "BMG",
"bmg-corinthians.png": "BMG Corinthians",
"bmo.png": "BMO",
"bni.png": "BNI",
"bnl.png": "BNL",
"bnpparibas.png": "BNP Paribas",
"boq.png": "BOQ",
"bperbanca.png": "BPER Banca",
"bpi.png": "BPI",
"bradesco.png": "Bradesco",
"bradesco-empresas.png": "Bradesco Empresas",
"bradesco-prime.png": "Bradesco Prime",
"brasilcard.png": "BrasilCard",
"brb.png": "Banco BRB",
"brde.png": "BRDE",
"bs2.png": "Banco BS2",
"btg.empresas.png": "BTG Empresas",
"btgpactual.png": "BTG Pactual",
"btgplus.png": "BTG Plus",
"buddy.bank.png": "Buddy Bank",
"bunq.png": "Bunq",
"bv.png": "Banco BV",
"c6bank.png": "C6 Bank",
"c-a.png": "C&A",
"cacique.png": "Banco Cacique",
"caisse.png": "Caisse",
"caixa.png": "Caixa",
"caixabank.png": "CaixaBank",
"caixageral.png": "Caixa Geral de Depósitos",
"caju.png": "Caju",
"c-a-pay.png": "C&A Pay",
"capitalone.png": "Capital One",
"carrefour.png": "Carrefour",
"cassadepositieprestiti.png": "Cassa Depositi e Prestiti",
"cdb.png": "CDB",
"cdp.png": "CDP",
"celcoin.png": "Celcoin",
"cetelem.png": "Cetelem",
"charlesschwab.png": "Charles Schwab",
"chinaconstructionbank.png": "China Construction Bank",
"cibc.png": "CIBC",
"cimbbank.png": "CIMB Bank",
"cimbniaga.png": "CIMB Niaga",
"citibank.png": "Citibank",
"clara.png": "Clara",
"clear.png": "Clear",
"clear-corretora.png": "Clear Corretora",
"coinbase.png": "Coinbase",
"commbank.png": "CommBank",
"commerzbank.png": "Commerzbank",
"conectcar.png": "ConectCar",
"contabilizei.bank.png": "Contabilizei Bank",
"contasimples.png": "Conta Simples",
"cooperativa-ailos.png": "Cooperativa Ailos",
"cooperativa-cresol.png": "Cooperativa Cresol",
"cooperativa-unilos.png": "Cooperativa Unilos",
"coopercard.png": "Coopercard",
"cooperforte.png": "Cooperforte",
"cora.png": "Cora",
"credicard.png": "Credicard",
"credicard-on.png": "Credicard On",
"credicard-zero.png": "Credicard Zero",
"credisis.png": "Credisis",
"creditagricole.png": "Credit Agricole",
"creditagricoleitaly.png": "Credit Agricole Italy",
"creditas.png": "Creditas",
"creditbankofmoscow.png": "Credit Bank of Moscow",
"creditdunord.png": "Credit du Nord",
"credito.agricola.png": "Crédito Agrícola",
"creditoemiliano.png": "Credito Emiliano",
"crefisa.png": "Crefisa",
"cruzeirodosul.png": "Cruzeiro do Sul",
"crypto-com.png": "Crypto.com",
"ctt.png": "CTT",
"danskebank.png": "Danske Bank",
"daycoval.png": "Daycoval",
"desjardins.png": "Desjardins",
"digio.png": "Digio",
"digiplus.png": "Digiplus",
"diin.png": "Diin",
"diners.png": "Diners",
"dinheiro.png": "Dinheiro",
"dmcard.png": "DM Card",
"dnbbank.png": "DNB Bank",
"dotz.png": "Dotz",
"dzbank.png": "DZ Bank",
"easynvest.png": "EasyInvest",
"ebanxgo.png": "EBANX Go",
"edenred.png": "Edenred",
"efi.bank.png": "EFI Bank",
"elliot.png": "Elliot",
"elo.png": "Elo",
"eqi.png": "EQI",
"eurobank.png": "Eurobank",
"evlibank.png": "Evli Bank",
"exodus.png": "Exodus",
"fidelity.png": "Fidelity",
"flash.png": "Flash",
"forexbank.png": "Forex Bank",
"fortbrasil.png": "Fort Brasil",
"foxbit.png": "Foxbit",
"freedom.24.png": "Freedom 24",
"garantibank.png": "Garantibank",
"gazprombank.png": "Gazprombank",
"genial.png": "Genial",
"grao.png": "Grão",
"handelsbanken.png": "Handelsbanken",
"havan.png": "Havan",
"hipercard.png": "Hipercard",
"hongleong.png": "Hong Leong",
"hotmart.png": "Hotmart",
"hsbc.png": "HSBC",
"hypovereinsbank.png": "Hypo Vereinsbank",
"icatu.png": "Icatu",
"icbc.png": "ICBC",
"iccreabanca.png": "ICCREA Banca",
"ifood-beneficios.png": "iFood Benefícios",
"ifood-conta-digital.png": "iFood Conta Digital",
"inbursa.png": "Inbursa",
"infinitepay.png": "InfinitePay",
"ing.png": "ING",
"intermedium.png": "Inter",
"interpj.png": "Inter PJ",
"intesa-san-paolo.png": "Intesa Sanpaolo",
"ion.png": "Ion",
"iqoption.png": "IQ Option",
"isbank.png": "Isbank",
"itau.png": "Itaú",
"itau-ion.png": "Itaú Ion",
"itaupersonnalite.png": "Itaú Personnalité",
"itau-uniclass.png": "Itaú Uniclass",
"iti.png": "Iti",
"jpbank.png": "JP Bank",
"jpmorgan.png": "JPMorgan",
"juno.png": "Juno",
"jyskebank.png": "Jyske Bank",
"kbank.png": "KBank",
"kbkookminbank.png": "KB Kookmin Bank",
"kdbbank.png": "KDB Bank",
"kebhanabank.png": "KEB Hana Bank",
"kfw.png": "KFW",
"kiwify.png": "Kiwify",
"klarna.png": "Klarna",
"krungthaibank.png": "Krungthai Bank",
"latam.pass.png": "Latam Pass",
"lhv.png": "LHV",
"lojasamericanas.png": "Lojas Americanas",
"macquariebank.png": "Macquarie Bank",
"magalu-pay.png": "Magalu Pay",
"magnetis.png": "Magnetis",
"mais.png": "Mais",
"mandiri.png": "Mandiri",
"marisa.png": "Marisa",
"master-black.png": "Master Black",
"mastercard.png": "Mastercard",
"maybank.png": "Maybank",
"mediobanca.png": "Mediobanca",
"meliuz.png": "Méliuz",
"mercadobitcoin.png": "Mercado Bitcoin",
"mercadolivre.png": "Mercado Livre",
"mercadopago.png": "Mercado Pago",
"mercadopagocartao.png": "Mercado Pago Cartão",
"mercantilbrasil.png": "Banco Mercantil",
"meu-util.png": "Meu Útil",
"midway.png": "Midway",
"milleniumbcp.png": "Millenium BCP",
"mizuhobank.png": "Mizuho Bank",
"modalmais.png": "Modal Mais",
"moey.png": "Moey",
"moip.png": "Moip",
"monetizze.png": "Monetizze",
"monetus.png": "Monetus",
"monzo-bank.png": "Monzo Bank",
"morganstanley.png": "Morgan Stanley",
"mps.png": "MPS",
"mufgbank.png": "MUFG Bank",
"n26.png": "N26",
"nab.png": "NAB",
"national.australia.bank.png": "National Australia Bank",
"nationalbank.png": "National Bank",
"neon.png": "Neon",
"next.png": "Next",
"nhbank.png": "NH Bank",
"nochubank.png": "Nochu Bank",
"noh.png": "NOH",
"nomad.png": "Nomad",
"nordea.png": "Nordea",
"nordeste.png": "Nordeste",
"nordnet.png": "Nordnet",
"novadax.png": "Novadax",
"novafatura.png": "Nova Fatura",
"novobancopt.png": "Novo Banco PT",
"novucard.png": "Novu Card",
"nubank.pj.png": "Nubank PJ",
"nubank.png": "Nubank",
"nubank-ultravioleta.png": "Nubank Ultravioleta",
"nuconta.png": "Nu Conta",
"nu-invest.png": "Nu Invest",
"nykredit.png": "Nykredit",
"olymp.trade.png": "Olymp Trade",
"omni.png": "Omni",
"opbank.png": "OP Bank",
"orama.png": "Orama",
"original.png": "Banco Original",
"otkritie.png": "Otkritie",
"pag.png": "Pag",
"pagar-me.png": "Pagar.me",
"pagbank.png": "PagBank",
"pagseguro.png": "PagSeguro",
"pan.png": "Pan",
"pao-acucar.png": "Pão de Açúcar",
"passfolio.png": "Passfolio",
"payoneer.png": "Payoneer",
"paypal.png": "PayPal",
"payu.png": "PayU",
"petrobras.png": "Petrobras",
"picpay.png": "PicPay",
"piraeusbank.png": "Piraeus Bank",
"pix.png": "Pix",
"players-bank.png": "Players Bank",
"pluxxe.png": "Pluxxe",
"pncfinancialservices.png": "PNC Financial Services",
"pocket.option.png": "Pocket Option",
"portoseguro.png": "Porto Seguro",
"primacredi.png": "Primacredi",
"proencamercado.png": "Proença Supermercados",
"promsvyazbank.png": "Promsvyazbank",
"publicbank.png": "Public Bank",
"quotex.png": "Quotex",
"rabobank.png": "Rabobank",
"raiffeisenbank.png": "Raiffeisen Bank",
"rappi-bank.png": "Rappi Bank",
"rbcroyalbank.png": "RBC Royal Bank",
"rbs.png": "RBS",
"rci.png": "RCI",
"recargapay.png": "RecargaPay",
"renner.png": "Renner",
"revolut.png": "Revolut",
"rhbbank.png": "RHB Bank",
"riachuelo.png": "Riachuelo",
"rico.png": "Rico",
"sabadell.png": "Sabadell",
"safra.png": "Safra",
"samsung.png": "Samsung",
"santander.png": "Santander",
"santander-private.png": "Santander Private",
"saraiva.png": "Saraiva",
"sberbank.png": "Sberbank",
"scb.png": "SCB",
"scotiabank.png": "Scotiabank",
"sebbank.png": "SEB Bank",
"sem-parar.png": "Sem Parar",
"senff.png": "Senff",
"serasa-consumidor.png": "Serasa Consumidor",
"shinhanbank.png": "Shinhan Bank",
"shopee.png": "Shopee",
"shoptime.png": "Shoptime",
"sicoob.png": "Sicoob",
"sicredi.png": "Sicredi",
"sisprime.png": "Sisprime",
"smbc.png": "SMBC",
"smiles.png": "Smiles",
"smpbank.png": "SMP Bank",
"snsbank.png": "SNS Bank",
"socialbank.png": "Social Bank",
"societegenerale.png": "Banco Societe Generale",
"sodexo.png": "Sodexo",
"sofi.png": "Sofi",
"sofisadireto.png": "Sofisa Direto",
"sparebank1.png": "SpareBank 1",
"sportingbet.png": "Sportingbet",
"spuerkeess.png": "Spuerkeess",
"standardchartered.png": "Standard Chartered",
"stanford-federal-credit-union.png": "Stanford Federal Credit Union",
"stone.png": "Stone",
"storebrandbank.png": "Storebrand Bank",
"submarino.png": "Submarino",
"sumup.png": "Sumup",
"suncorpbank.png": "Suncorp Bank",
"superdigital.png": "Super Digital",
"swedbank.png": "Swedbank",
"swile.png": "Swile",
"sydbank.png": "SYD Bank",
"tangerine.png": "Tangerine",
"tdameritrade.png": "TD Ameritrade",
"tdbank.png": "TD Bank",
"techcombank.png": "Techcombank",
"telekom.png": "Telekom",
"tesourodireto.png": "Tesouro Direto",
"tesouronacional.png": "Tesouro Nacional",
"ticket.png": "Ticket",
"tinkoff.png": "Tinkoff",
"tmbbank.png": "TMB Bank",
"ton.png": "Ton",
"toroinvestimentos.png": "Toro Investimentos",
"trade.republic.png": "Trade Republic",
"trading.212.png": "Trading 212",
"transferwise.png": "TransferWise",
"tribanco.png": "Tribanco",
"trigg.png": "Trigg",
"tudoazul.png": "Tudo Azul",
"uber.drive.png": "Uber Drive",
"ubibanca.png": "Ubi Banca",
"unicred.png": "Unicred",
"unicreditbank.png": "UniCredit Bank",
"unimed.seguros.png": "Unimed Seguros",
"unipolbanca.png": "Unipol Banca",
"uniprime.png": "Uniprime",
"up.brasil.png": "Up Brasil",
"urbeme.png": "Urbeme",
"urpay.png": "UrPay",
"usbank.png": "U.S. Bank",
"veloe.png": "Veloe",
"venmo.png": "Venmo",
"verocard.png": "Verocard",
"viacredi.png": "Viacredi",
"vietcombank.png": "Vietcombank",
"vietinbank.png": "VietinBank",
"visa.png": "Visa",
"vitreo.png": "Vitreo",
"votorantim.png": "Votorantim",
"vr.png": "VR",
"vtbbank.png": "VTB Bank",
"vuon.png": "Vuon",
"warren.png": "Warren",
"wellsfargo.png": "Wells Fargo",
"westpac.png": "Westpac",
"wiipo.png": "Wiipo",
"will-bank.png": "Will Bank",
"wirecard.png": "Wirecard",
"wise.png": "Wise",
"woop.png": "Woop",
"xdex.png": "XDEX",
"xm.png": "XM",
"xp.png": "XP Investimentos",
"xtb.png": "XTB",
"yapikredi.png": "Yapi Kredi",
"z1.png": "Z1",
"zilch.png": "Zilch",
"ziraatbank.png": "Ziraat Bank",
"zro-bank.png": "ZRO Bank",
};

View File

@@ -1,9 +1,34 @@
import { logoDisplayNames } from "./display-names";
/**
* Normalizes logo path to get just the filename
*/
export const normalizeLogo = (logo?: string | null) =>
logo?.split("/").filter(Boolean).pop() ?? "";
/**
* Normalizes a string for accent-insensitive search.
* Removes diacritics and converts to lowercase.
*/
export const normalizeForSearch = (text: string): string =>
text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
/**
* Gets the display name for a logo, using a manual dictionary first
* and falling back to deriveNameFromLogo for unknown logos.
*/
export const getLogoDisplayName = (logo?: string | null): string => {
if (!logo) return "";
const fileName = normalizeLogo(logo);
if (!fileName) return "";
return logoDisplayNames[fileName.toLowerCase()] ?? deriveNameFromLogo(logo);
};
/**
* Derives a display name from a logo filename
* @param logo - Logo path or filename