mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dcd30010e | ||
|
|
d589df6993 | ||
|
|
8a19f0f311 | ||
|
|
887885cd98 | ||
|
|
7a0e33efd8 | ||
|
|
b9557961e5 | ||
|
|
53c8e47981 | ||
|
|
adc9292cd8 | ||
|
|
b95d6f6752 | ||
|
|
c9f667a065 | ||
|
|
01d9c6ea05 | ||
|
|
d383d2db91 | ||
|
|
7a8d01debe | ||
|
|
3be15d3b15 | ||
|
|
fea9cf81d8 | ||
|
|
7a10d431ab | ||
|
|
b7343eb235 | ||
|
|
3bcc392f38 | ||
|
|
5241de44af | ||
|
|
1a75662120 | ||
|
|
7ca3f92467 | ||
|
|
6b044f3bc5 | ||
|
|
4e8f9cc5fa | ||
|
|
b6659ef66e | ||
|
|
21d7396c80 |
@@ -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=
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -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
32
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -5,6 +5,84 @@ 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.
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -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).
|
||||
|
||||
44
package.json
44
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.5.7",
|
||||
"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
3965
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
BIN
public/logos/bipa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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()} />;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
dividedFilter: null,
|
||||
amountMinFilter: null,
|
||||
amountMaxFilter: null,
|
||||
dateStartFilter: null,
|
||||
dateEndFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,8 @@ export type Card = {
|
||||
accountName: string;
|
||||
limitInUse: number;
|
||||
limitAvailable: number;
|
||||
currentInvoiceAmount: number;
|
||||
currentInvoiceLabel: string;
|
||||
};
|
||||
|
||||
export type CardFormValues = {
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -64,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
|
||||
@@ -83,6 +83,10 @@ export function InstallmentGroupCard({
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
@@ -153,7 +157,7 @@ export function InstallmentGroupCard({
|
||||
<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}
|
||||
@@ -180,8 +184,8 @@ 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 && (
|
||||
@@ -198,34 +202,28 @@ export function InstallmentGroupCard({
|
||||
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="secondary"
|
||||
size="sm"
|
||||
className="w-full gap-1.5"
|
||||
className="relative w-full justify-center gap-1.5"
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
>
|
||||
<RiFileList2Line className="size-4" />
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -153,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,
|
||||
@@ -168,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)
|
||||
@@ -177,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 já foram pagas ou antecipadas.
|
||||
Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem
|
||||
aqui.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -112,6 +114,7 @@ export function TransactionDialog({
|
||||
defaultPayerId,
|
||||
defaultPeriod,
|
||||
{
|
||||
defaultAccountId,
|
||||
defaultCardId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -151,6 +154,7 @@ export function TransactionDialog({
|
||||
transaction,
|
||||
defaultPayerId,
|
||||
defaultPeriod,
|
||||
defaultAccountId,
|
||||
defaultCardId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -327,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)
|
||||
@@ -559,7 +569,7 @@ export function TransactionDialog({
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
|
||||
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">
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -174,7 +174,7 @@ export function TransactionsTable({
|
||||
: getPaginationRowModel(),
|
||||
manualPagination: isServerPaginated,
|
||||
pageCount: serverPagination?.totalPages,
|
||||
enableRowSelection: true,
|
||||
enableRowSelection: (row) => !row.original.readonly,
|
||||
});
|
||||
|
||||
const rowModel = table.getRowModel();
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -11,6 +11,8 @@ type TransactionExportFilters = {
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
dateStartFilter: string | null;
|
||||
dateEndFilter: string | null;
|
||||
};
|
||||
|
||||
export type TransactionsExportContext = {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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[]) =>
|
||||
|
||||
17
src/proxy.ts
17
src/proxy.ts
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
src/shared/lib/auth/signup.ts
Normal file
4
src/shared/lib/auth/signup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isSignupDisabled(): boolean {
|
||||
const value = process.env.DISABLE_SIGNUP?.trim().toLowerCase();
|
||||
return value === "true";
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
433
src/shared/lib/logo/display-names.ts
Normal file
433
src/shared/lib/logo/display-names.ts
Normal 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",
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user