mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bcc392f38 | ||
|
|
5241de44af | ||
|
|
1a75662120 | ||
|
|
7ca3f92467 | ||
|
|
6b044f3bc5 | ||
|
|
4e8f9cc5fa | ||
|
|
b6659ef66e | ||
|
|
21d7396c80 | ||
|
|
3a768bc8ba | ||
|
|
8a03a50132 | ||
|
|
246bb14a00 | ||
|
|
86bcffec66 | ||
|
|
81e7151876 | ||
|
|
0bb664884a | ||
|
|
f02958df1d | ||
|
|
c4c52c02ab | ||
|
|
c9239c4f3c | ||
|
|
7128cc0ae7 | ||
|
|
467f71493d | ||
|
|
0cec10ede3 |
@@ -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=
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
72
CHANGELOG.md
72
CHANGELOG.md
@@ -5,6 +5,78 @@ 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.0] - 2026-05-21
|
||||
|
||||
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.
|
||||
|
||||
### Adicionado
|
||||
- Autenticação: nova variável `DISABLE_SIGNUP=true` para bloquear novos cadastros. Quando ativa, a tela de cadastro deixa de aparecer na navegação, `/signup` redireciona para login/dashboard e a API de signup responde `403`.
|
||||
- Lançamentos: compras parceladas agora podem começar em uma parcela intermediária, como `5 de 10`. O sistema gera apenas as parcelas restantes e preserva o cálculo do valor unitário com base no total original.
|
||||
- Logos: adicionado o logo da Bipa à biblioteca local de marcas.
|
||||
- Relatórios: a análise de parcelas agora separa parcelas acompanhadas daquelas que ficaram fora do acompanhamento quando o parcelamento começa no meio da série.
|
||||
|
||||
### Alterado
|
||||
- Contas: a página de extrato em `/accounts/[accountId]` voltou a exibir os botões "Nova Receita" e "Nova Despesa", alinhando o fluxo com as demais telas de lançamentos.
|
||||
- Cartões: os cards de `/cards` agora mostram o valor da fatura do mês atual junto dos indicadores de limite. O limite utilizado passa a considerar faturas em aberto, não apenas o status interno do lançamento.
|
||||
- Lançamentos: ao criar um lançamento a partir do extrato de uma conta, o diálogo já abre com essa conta selecionada como destino padrão.
|
||||
- Importação: os controles globais da revisão de extrato foram realinhados à esquerda, com espaçamento mais compacto e larguras mais consistentes.
|
||||
|
||||
### Corrigido
|
||||
- Dashboard: o widget "Status de Pagamento" voltou a mostrar corretamente os valores em "A Pagar", somando despesas pelo valor absoluto e mantendo reembolsos como abatimento.
|
||||
- Importação: datas vindas de planilhas agora preservam o dia informado no Excel, evitando que `20/05/2026` apareça como `19/05/2026` em fusos como `America/Sao_Paulo`.
|
||||
- Importação: o seletor de categoria por linha agora mostra apenas categorias compatíveis com o tipo detectado do lançamento, separando receitas e despesas durante a revisão do extrato.
|
||||
- Importação: cada linha da revisão de extrato agora permite escolher uma pessoa específica, enquanto o campo global continua servindo como atalho para aplicar a pessoa nos lançamentos selecionados.
|
||||
- Lançamentos: despesas comuns na categoria `Pagamentos` voltaram a poder ser editadas, removidas, copiadas e importadas. A proteção continua valendo apenas para pagamentos automáticos de fatura com nota técnica `AUTO_FATURA:`.
|
||||
|
||||
### Dependências
|
||||
- Stack core: `pnpm` 10.33.0 → 11.1.3.
|
||||
- Auth: `better-auth` e `@better-auth/passkey` 1.6.10 → 1.6.11.
|
||||
- AI SDKs: `@ai-sdk/anthropic` 3.0.76 → 3.0.78, `@ai-sdk/google` 3.0.71 → 3.0.75, `@ai-sdk/openai` 3.0.63 → 3.0.64 e `ai` 6.0.177 → 6.0.185.
|
||||
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1045.0 → 3.1050.0.
|
||||
- UI e dados: `@tanstack/react-query` 5.100.9 → 5.100.11, `date-fns` 4.1.0 → 4.2.1, `jspdf-autotable` 5.0.7 → 5.0.8, `pg` 8.20.0 → 8.21.0 e `react-day-picker` 10.0.0 → 10.0.1.
|
||||
- Dev tooling: `@types/node` 25.6.2 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `knip` 6.12.2 → 6.14.1, `tsx` 4.21.0 → 4.22.3 e novo `babel-plugin-react-compiler` 1.0.0.
|
||||
|
||||
## [2.5.7] - 2026-05-14
|
||||
|
||||
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
|
||||
|
||||
### Alterado
|
||||
- Relatórios: em `/reports/installment-analysis`, os cards de parcelas passam a usar o logo do estabelecimento como avatar principal; o logo do cartão agora aparece menor ao lado do nome do cartão, tanto no card quanto no modal de detalhes.
|
||||
- Relatórios: a página de análise de parcelas pré-carrega os mapeamentos de logos de estabelecimentos para evitar troca visual após o primeiro render.
|
||||
- Lançamentos: o campo de anexos no modal agora aceita arquivos colados com `Ctrl+V`, mantendo o botão para buscar arquivos normalmente.
|
||||
- Lançamentos: o modal agora usa uma única área interna de rolagem, com cabeçalho e rodapé estáveis, reduzindo travadas ao rolar e ao abrir "Condições, anotações e anexos".
|
||||
- Anotações: tarefas agora podem ser editadas inline no modal "Atualizar anotação"; clicar no texto abre o input e o botão de remover vira botão de salvar naquela linha.
|
||||
|
||||
### Corrigido
|
||||
- Relatórios: o join com cartões na análise de parcelas agora também valida `cards.userId`, mantendo o filtro de ownership explícito na consulta.
|
||||
|
||||
## [2.5.6] - 2026-05-07
|
||||
|
||||
Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.
|
||||
|
||||
### Adicionado
|
||||
- Lançamentos: filtro por faixa de valor (mín/máx) com debounce e persistência via query string (`amountMin`/`amountMax`).
|
||||
- Lançamentos: botão "Limpar" discreto ao lado do botão "Filtros", visível apenas quando há filtros ativos.
|
||||
- Modal de lançamento: toggle compacto R$/% no card "Dividir lançamento", permitindo distribuir o valor por porcentagem entre as pessoas. Cada input em modo % exibe o valor convertido em R$ logo abaixo, no mesmo padrão visual do `InlinePeriodPicker`.
|
||||
- Modal de lançamento: indicador de orçamento ao lado do nome da categoria selecionada, mostrando `R$ gasto de R$ orçado (%)` com cores semânticas (verde / âmbar / vermelho) conforme o consumo. Suprimido quando o input divide a linha com o tipo de transação (caso pré-lançamentos). Implementado via `getCategoryBudgetSummaryAction` e `fetchCategoryBudgetSummary` em `features/budgets`.
|
||||
|
||||
### Alterado
|
||||
- Calculadora: display com tamanho de fonte adaptativo (de `text-3xl` a `text-sm`) conforme o comprimento da expressão, mais `truncate` funcional via `min-w-0` nos containers flex. Resolve o overflow do modal com valores muito longos (ex: `9.999.999.999 × 9.999.999.999`).
|
||||
|
||||
### Corrigido
|
||||
- Pessoas: "Totais do mês" em `/payers/[id]` deixa de somar lançamentos vinculados a contas marcadas como `excludeFromBalance` (ex: "Ajuste de saldo"). Adicionado `excludeTransactionsFromExcludedAccounts()` em 6 queries de `src/shared/lib/payers/details.ts`.
|
||||
- Orçamentos: `fetchBudgetsForUser` e `fetchCategoryBudgetSummary` agora respeitam o filtro de contas excluídas do saldo, alinhando o gasto exibido na tela de Orçamentos com o badge de orçamento dentro do modal de lançamento.
|
||||
- Lançamentos: tabela de resultados agora reflete corretamente a remoção de um valor em filtros multi-seleção (Pessoa, Conta/Cartão, Categoria, Condição, Forma de Pagamento). Adicionado `router.refresh()` em `handleMultiFilterChange` para invalidar o cache de segmento do router (issue #54).
|
||||
|
||||
### Dependências
|
||||
- Stack core: `next` 16.2.4 → 16.2.6, `react`/`react-dom` 19.2.5 → 19.2.6.
|
||||
- UI: `react-day-picker` 9 → 10 (major), `tailwindcss` / `@tailwindcss/postcss` 4.2.4 → 4.3.0, `tailwind-merge` 3.5.0 → 3.6.0.
|
||||
- Auth: `better-auth` 1.6.9 → 1.6.10 e `@better-auth/passkey` 1.6.9 → 1.6.10.
|
||||
- AI SDKs: `@ai-sdk/anthropic` 3.0.74 → 3.0.76, `@ai-sdk/google` 3.0.67 → 3.0.71, `@ai-sdk/openai` 3.0.60 → 3.0.63, `ai` 6.0.175 → 6.0.177.
|
||||
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1042.0 → 3.1045.0.
|
||||
- E-mail: `resend` 6.12.2 → 6.12.3.
|
||||
- Dev tooling: `@biomejs/biome` 2.4.14 → 2.4.15, `knip` 6.11.0 → 6.12.2, `@types/node` 25.6.0 → 25.6.2.
|
||||
|
||||
## [2.5.5] - 2026-05-06
|
||||
|
||||
Esta versão melhora a navegação por históricos e lançamentos. O changelog ganhou uma linha do tempo mais leve, colapsável e fácil de escanear; os filtros de lançamentos passam a aceitar múltiplas pessoas, categorias, formas de pagamento, condições e contas/cartões na mesma busca; e os diálogos adotam as animações compartilhadas do design system. Também há pequenos polimentos de texto e layout para deixar a interface mais consistente.
|
||||
|
||||
@@ -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/)
|
||||
@@ -127,10 +127,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 +444,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=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
60
package.json
60
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"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.74",
|
||||
"@ai-sdk/google": "^3.0.67",
|
||||
"@ai-sdk/openai": "^3.0.60",
|
||||
"@aws-sdk/client-s3": "^3.1042.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1042.0",
|
||||
"@better-auth/passkey": "^1.6.9",
|
||||
"@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,53 +63,49 @@
|
||||
"@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.175",
|
||||
"better-auth": "1.6.9",
|
||||
"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",
|
||||
"next": "16.2.4",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"pg": "8.20.0",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.5",
|
||||
"pg": "8.21.0",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.6",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.12.2",
|
||||
"resend": "^6.12.3",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"defu": "6.1.7"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.14",
|
||||
"@tailwindcss/postcss": "4.2.4",
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.6.0",
|
||||
"@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.11.0",
|
||||
"tailwindcss": "4.2.4",
|
||||
"tsx": "4.21.0",
|
||||
"knip": "^6.14.1",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
4659
pnpm-lock.yaml
generated
4659
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,19 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- esbuild
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
neverBuiltDependencies: []
|
||||
|
||||
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;
|
||||
|
||||
@@ -85,6 +85,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
settledFilter: null,
|
||||
attachmentFilter: null,
|
||||
dividedFilter: null,
|
||||
amountMinFilter: null,
|
||||
amountMaxFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { connection } from "next/server";
|
||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const data = await fetchInstallmentAnalysis(user.id);
|
||||
const logoMappings = await prefetchLogoMappings(
|
||||
user.id,
|
||||
data.installmentGroups.map((group) => group.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 pb-8">
|
||||
<InstallmentAnalysisPage data={data} />
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<InstallmentAnalysisPage data={data} />
|
||||
</LogoPrefetchProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { budgets, categories } from "@/db/schema";
|
||||
import {
|
||||
type CategoryBudgetSummary,
|
||||
fetchCategoryBudgetSummary,
|
||||
} from "@/features/budgets/queries";
|
||||
import {
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
@@ -204,6 +208,34 @@ export async function deleteBudgetAction(
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryBudgetSummarySchema = z.object({
|
||||
categoryId: uuidSchema("Category"),
|
||||
period: periodSchema,
|
||||
});
|
||||
|
||||
type GetCategoryBudgetSummaryInput = z.input<
|
||||
typeof getCategoryBudgetSummarySchema
|
||||
>;
|
||||
|
||||
export async function getCategoryBudgetSummaryAction(
|
||||
input: GetCategoryBudgetSummaryInput,
|
||||
): Promise<ActionResult<CategoryBudgetSummary | null>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = getCategoryBudgetSummarySchema.parse(input);
|
||||
const summary = await fetchCategoryBudgetSummary(
|
||||
user.id,
|
||||
data.categoryId,
|
||||
data.period,
|
||||
);
|
||||
return { success: true, message: "ok", data: summary };
|
||||
} catch (error) {
|
||||
return handleActionError(
|
||||
error,
|
||||
) as ActionResult<CategoryBudgetSummary | null>;
|
||||
}
|
||||
}
|
||||
|
||||
const duplicatePreviousMonthSchema = z.object({
|
||||
period: periodSchema,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm";
|
||||
import { budgets, categories, transactions } from "@/db/schema";
|
||||
import {
|
||||
budgets,
|
||||
categories,
|
||||
financialAccounts,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
|
||||
@@ -75,6 +81,10 @@ export async function fetchBudgetsForUser(
|
||||
totalAmount: sum(transactions.amount).as("totalAmount"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -86,6 +96,7 @@ export async function fetchBudgetsForUser(
|
||||
isNull(transactions.note),
|
||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.categoryId);
|
||||
@@ -127,3 +138,57 @@ export async function fetchBudgetsForUser(
|
||||
|
||||
return { budgets: budgetList, categoriesOptions };
|
||||
}
|
||||
|
||||
export type CategoryBudgetSummary = {
|
||||
amount: number;
|
||||
spent: number;
|
||||
};
|
||||
|
||||
export async function fetchCategoryBudgetSummary(
|
||||
userId: string,
|
||||
categoryId: string,
|
||||
period: string,
|
||||
): Promise<CategoryBudgetSummary | null> {
|
||||
const [adminPayerId, budget] = await Promise.all([
|
||||
getAdminPayerId(userId),
|
||||
db.query.budgets.findFirst({
|
||||
columns: { amount: true },
|
||||
where: and(
|
||||
eq(budgets.userId, userId),
|
||||
eq(budgets.categoryId, categoryId),
|
||||
eq(budgets.period, period),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!adminPayerId || !budget) return null;
|
||||
|
||||
const totals = await db
|
||||
.select({
|
||||
totalAmount: sum(transactions.amount).as("totalAmount"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
eq(transactions.categoryId, categoryId),
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
amount: toNumber(budget.amount),
|
||||
spent: Math.abs(toNumber(totals[0]?.totalAmount ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import {
|
||||
RiBankCard2Line,
|
||||
RiCheckboxCircleFill,
|
||||
RiEyeLine,
|
||||
RiFileList2Line,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
@@ -62,8 +64,8 @@ export function InstallmentGroupCard({
|
||||
const hasSelection = selectedInstallments.size > 0;
|
||||
|
||||
const progress =
|
||||
group.totalInstallments > 0
|
||||
? (group.paidInstallments / group.totalInstallments) * 100
|
||||
group.trackedInstallments > 0
|
||||
? (group.paidInstallments / group.trackedInstallments) * 100
|
||||
: 0;
|
||||
|
||||
const selectedAmount = group.pendingInstallments
|
||||
@@ -79,6 +81,12 @@ export function InstallmentGroupCard({
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
||||
const cardName = group.cartaoName ?? "Compra parcelada";
|
||||
const untrackedLabel =
|
||||
group.untrackedInstallments === 1
|
||||
? "1 parcela anterior fora do acompanhamento"
|
||||
: `${group.untrackedInstallments} parcelas anteriores fora do acompanhamento`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -111,25 +119,24 @@ export function InstallmentGroupCard({
|
||||
{/* Info principal */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{group.cartaoLogo ? (
|
||||
<Image
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-10 flex items-center justify-center">
|
||||
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<EstablishmentLogo name={group.name} size={40} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
{group.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{group.cartaoName ?? "Compra parcelada"}
|
||||
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
|
||||
{cardLogoSrc ? (
|
||||
<Image
|
||||
src={cardLogoSrc}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{cardName}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,10 +154,10 @@ export function InstallmentGroupCard({
|
||||
|
||||
<CardContent>
|
||||
{/* Grid de valores */}
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
Valor total
|
||||
Valor acompanhado
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
@@ -165,7 +172,7 @@ export function InstallmentGroupCard({
|
||||
amount={pendingAmount}
|
||||
className={cn(
|
||||
"text-lg font-semibold",
|
||||
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
|
||||
pendingAmount > 0 ? "text-primary" : "text-success",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -177,20 +184,27 @@ export function InstallmentGroupCard({
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<RiCheckboxCircleFill className="size-3.5 text-success" />
|
||||
<span>
|
||||
{group.paidInstallments} de {group.totalInstallments} parcelas
|
||||
pagas
|
||||
{group.paidInstallments} de {group.trackedInstallments}{" "}
|
||||
parcelas acompanhadas pagas
|
||||
</span>
|
||||
</div>
|
||||
{unpaidCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<RiTimeLine className="size-3.5 text-amber-600" />
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<span>
|
||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={progress} className="h-2.5" />
|
||||
<Progress
|
||||
value={progress}
|
||||
className="h-2.5 bg-muted"
|
||||
indicatorClassName="bg-success"
|
||||
/>
|
||||
{group.untrackedInstallments > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{untrackedLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Valor selecionado */}
|
||||
@@ -212,13 +226,13 @@ export function InstallmentGroupCard({
|
||||
{/* Botão para abrir detalhes */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full gap-1.5"
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
>
|
||||
<RiEyeLine className="size-4" />
|
||||
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
||||
<RiFileList2Line className="size-4" />
|
||||
detalhes ({group.pendingInstallments.length} parcelas)
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -228,18 +242,26 @@ export function InstallmentGroupCard({
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{group.cartaoLogo ? (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||
<EstablishmentLogo name={group.name} size={32} />
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{group.name}
|
||||
</DialogTitle>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{cardLogoSrc ? (
|
||||
<Image
|
||||
src={cardLogoSrc}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={14}
|
||||
height={14}
|
||||
className="size-3.5 shrink-0 rounded-full object-cover opacity-75"
|
||||
/>
|
||||
) : (
|
||||
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{cardName}</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogTitle className="text-base">{group.name}</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Detalhes das parcelas do grupo {group.name}
|
||||
|
||||
@@ -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;
|
||||
@@ -92,7 +95,10 @@ export async function fetchInstallmentAnalysis(
|
||||
cartaoLogo: cards.logo,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
cards,
|
||||
and(eq(transactions.cardId, cards.id), eq(cards.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -150,6 +156,12 @@ export async function fetchInstallmentAnalysis(
|
||||
cartaoDueDay: row.cartaoDueDay,
|
||||
cartaoLogo: row.cartaoLogo,
|
||||
totalInstallments: row.installmentCount ?? 0,
|
||||
trackedStartInstallment: installmentDetail.currentInstallment,
|
||||
trackedInstallments: 1,
|
||||
untrackedInstallments: Math.max(
|
||||
0,
|
||||
installmentDetail.currentInstallment - 1,
|
||||
),
|
||||
paidInstallments: 0,
|
||||
pendingInstallments: [installmentDetail],
|
||||
totalPendingAmount: amount,
|
||||
@@ -165,7 +177,13 @@ export async function fetchInstallmentAnalysis(
|
||||
const paidCount = group.pendingInstallments.filter(
|
||||
(i) => i.isSettled,
|
||||
).length;
|
||||
const trackedStartInstallment = Math.min(
|
||||
...group.pendingInstallments.map((i) => i.currentInstallment),
|
||||
);
|
||||
group.paidInstallments = paidCount;
|
||||
group.trackedStartInstallment = trackedStartInstallment;
|
||||
group.trackedInstallments = group.pendingInstallments.length;
|
||||
group.untrackedInstallments = Math.max(0, trackedStartInstallment - 1);
|
||||
return group;
|
||||
})
|
||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleFill, RiDeleteBinLine } from "@remixicon/react";
|
||||
import {
|
||||
RiAddCircleFill,
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
@@ -69,10 +73,13 @@ export function NoteDialog({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [newTaskText, setNewTaskText] = useState("");
|
||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||
const [editingTaskText, setEditingTaskText] = useState("");
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||
const newTaskRef = useRef<HTMLInputElement>(null);
|
||||
const editingTaskRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
@@ -90,6 +97,8 @@ export function NoteDialog({
|
||||
resetForm(buildInitialValues(note));
|
||||
setErrorMessage(null);
|
||||
setNewTaskText("");
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
requestAnimationFrame(() => titleRef.current?.focus());
|
||||
}
|
||||
}, [dialogOpen, note, resetForm]);
|
||||
@@ -126,7 +135,12 @@ export function NoteDialog({
|
||||
formState.description.trim() === (note?.description ?? "").trim() &&
|
||||
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
|
||||
|
||||
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
|
||||
const disableSubmit =
|
||||
isPending ||
|
||||
onlySpaces ||
|
||||
unchanged ||
|
||||
invalidLen ||
|
||||
Boolean(editingTaskId);
|
||||
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
setDialogOpen(v);
|
||||
@@ -159,6 +173,10 @@ export function NoteDialog({
|
||||
"tasks",
|
||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||
);
|
||||
if (editingTaskId === taskId) {
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTask = (taskId: string) => {
|
||||
@@ -170,6 +188,40 @@ export function NoteDialog({
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartEditTask = (task: Task) => {
|
||||
if (isPending) return;
|
||||
|
||||
setEditingTaskId(task.id);
|
||||
setEditingTaskText(task.text);
|
||||
requestAnimationFrame(() => {
|
||||
editingTaskRef.current?.focus();
|
||||
editingTaskRef.current?.select();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveTask = (taskId: string) => {
|
||||
const text = normalize(editingTaskText);
|
||||
if (!text) {
|
||||
toast.error("O texto da tarefa não pode estar vazio.");
|
||||
editingTaskRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).map((t) =>
|
||||
t.id === taskId ? { ...t, text } : t,
|
||||
),
|
||||
);
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
};
|
||||
|
||||
const handleCancelEditTask = () => {
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
@@ -373,33 +425,78 @@ export function NoteDialog({
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
||||
checked={task.completed}
|
||||
onCheckedChange={() => handleToggleTask(task.id)}
|
||||
disabled={isPending}
|
||||
aria-label={`Marcar "${task.text}" como ${
|
||||
task.completed ? "não concluída" : "concluída"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 text-sm wrap-break-word",
|
||||
task.completed
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
{editingTaskId === task.id ? (
|
||||
<Input
|
||||
ref={editingTaskRef}
|
||||
value={editingTaskText}
|
||||
onChange={(e) => setEditingTaskText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveTask(task.id);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCancelEditTask();
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="h-8 min-w-0 flex-1"
|
||||
aria-label={`Editar "${task.text}"`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
||||
checked={task.completed}
|
||||
onCheckedChange={() => handleToggleTask(task.id)}
|
||||
disabled={isPending}
|
||||
aria-label={`Marcar "${task.text}" como ${
|
||||
task.completed ? "não concluída" : "concluída"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartEditTask(task)}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 cursor-text text-left text-sm wrap-break-word transition-colors hover:text-primary disabled:cursor-not-allowed",
|
||||
task.completed
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTask(task.id)}
|
||||
onClick={() =>
|
||||
editingTaskId === task.id
|
||||
? handleSaveTask(task.id)
|
||||
: handleRemoveTask(task.id)
|
||||
}
|
||||
disabled={isPending}
|
||||
className="shrink-0 text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
aria-label={`Remover "${task.text}"`}
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
editingTaskId === task.id
|
||||
? "text-success hover:text-success/80"
|
||||
: "text-muted-foreground/50 hover:text-destructive",
|
||||
)}
|
||||
aria-label={
|
||||
editingTaskId === task.id
|
||||
? `Salvar "${task.text}"`
|
||||
: `Remover "${task.text}"`
|
||||
}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
{editingTaskId === task.id ? (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -124,8 +124,10 @@ function TimelineItem({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-6 space-y-3 min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="font-semibold font-mono">v{version.version}</h3>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<h3 className="font-semibold font-mono text-lg">
|
||||
v{version.version}
|
||||
</h3>
|
||||
{isLatest ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Atual
|
||||
@@ -142,7 +144,7 @@ function TimelineItem({
|
||||
</div>
|
||||
|
||||
{version.summary ? (
|
||||
<Card className="p-4">
|
||||
<Card className="p-6">
|
||||
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
|
||||
{version.summary}
|
||||
</blockquote>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,8 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
settledFilter: z.string().nullable(),
|
||||
attachmentFilter: z.string().nullable(),
|
||||
dividedFilter: z.string().nullable(),
|
||||
amountMinFilter: z.number().nullable(),
|
||||
amountMaxFilter: z.number().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,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
} from "@/features/transactions/lib/attachments-config";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
getFilesFromClipboard,
|
||||
isTextEditingTarget,
|
||||
validateAttachmentFile,
|
||||
} from "./attachment-file-utils";
|
||||
|
||||
interface AttachmentFilePickerProps {
|
||||
files: File[];
|
||||
@@ -22,34 +27,54 @@ export function AttachmentFilePicker({
|
||||
onRemove,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
}: AttachmentFilePickerProps) {
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addFile(file: File) {
|
||||
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||
if (!validation.ok) {
|
||||
toast.error(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd(file);
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const selected = e.target.files?.[0];
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.includes(
|
||||
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||
)
|
||||
) {
|
||||
toast.error(
|
||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.size > maxFileSizeBytes) {
|
||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd(selected);
|
||||
addFile(selected);
|
||||
}
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||
const pastedFiles = getFilesFromClipboard(event);
|
||||
if (pastedFiles.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
for (const file of pastedFiles) {
|
||||
addFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocumentPaste(event: ClipboardEvent) {
|
||||
if (isTextEditingTarget(event.target)) return;
|
||||
|
||||
const pastedFiles = getFilesFromClipboard(event);
|
||||
if (pastedFiles.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
for (const file of pastedFiles) {
|
||||
addFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handleDocumentPaste);
|
||||
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">Anexos</p>
|
||||
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<RiAttachment2 className="size-4" />
|
||||
Adicionar anexo
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||
MB
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
} from "@/features/transactions/lib/attachments-config";
|
||||
|
||||
type AttachmentValidationResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export function validateAttachmentFile(
|
||||
file: File,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
): AttachmentValidationResult {
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.includes(
|
||||
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||
};
|
||||
}
|
||||
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
if (file.size > maxFileSizeBytes) {
|
||||
return { ok: false, error: `O arquivo deve ter no máximo ${maxSizeMb}MB.` };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
type ClipboardLikeEvent = ClipboardEvent | React.ClipboardEvent;
|
||||
|
||||
export function getFilesFromClipboard(event: ClipboardLikeEvent): File[] {
|
||||
const files = Array.from(event.clipboardData?.files ?? []);
|
||||
if (files.length > 0) return files;
|
||||
|
||||
return Array.from(event.clipboardData?.items ?? [])
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
}
|
||||
|
||||
export function isTextEditingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
return (
|
||||
tagName === "input" ||
|
||||
tagName === "textarea" ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[contenteditable="true"]') !== null
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RiAttachment2 } from "@remixicon/react";
|
||||
import { useRef, useTransition } from "react";
|
||||
import { useEffect, useRef, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
confirmAttachmentUploadAction,
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
} from "@/features/transactions/lib/attachments-config";
|
||||
import {
|
||||
getFilesFromClipboard,
|
||||
isTextEditingTarget,
|
||||
validateAttachmentFile,
|
||||
} from "./attachment-file-utils";
|
||||
|
||||
interface AttachmentUploadProps {
|
||||
transactionId: string;
|
||||
@@ -25,7 +30,6 @@ export function AttachmentUpload({
|
||||
onPendingUpload,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
}: AttachmentUploadProps) {
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -36,19 +40,13 @@ export function AttachmentUpload({
|
||||
|
||||
if (!file) return;
|
||||
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.includes(
|
||||
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||
)
|
||||
) {
|
||||
toast.error(
|
||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleFile(file);
|
||||
}
|
||||
|
||||
if (file.size > maxFileSizeBytes) {
|
||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||
function handleFile(file: File) {
|
||||
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||
if (!validation.ok) {
|
||||
toast.error(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +92,29 @@ export function AttachmentUpload({
|
||||
});
|
||||
}
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||
const [file] = getFilesFromClipboard(event);
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
handleFile(file);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocumentPaste(event: ClipboardEvent) {
|
||||
if (isPending || isTextEditingTarget(event.target)) return;
|
||||
|
||||
const [file] = getFilesFromClipboard(event);
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
handleFile(file);
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handleDocumentPaste);
|
||||
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@@ -107,6 +128,7 @@ export function AttachmentUpload({
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onPaste={handlePaste}
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -115,7 +137,8 @@ export function AttachmentUpload({
|
||||
</span>
|
||||
{!isPending && (
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||
MB
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -70,6 +70,23 @@ export function BulkActionDialog({
|
||||
return "Este e os próximos lançamentos";
|
||||
};
|
||||
|
||||
const getPeriodLabel = () => {
|
||||
if (seriesType === "installment" && currentNumber && totalCount) {
|
||||
return `Todas as pessoas desta parcela (${currentNumber}/${totalCount})`;
|
||||
}
|
||||
if (seriesType === "installment") {
|
||||
return "Todas as pessoas desta parcela";
|
||||
}
|
||||
return "Todas as pessoas deste lançamento";
|
||||
};
|
||||
|
||||
const getPeriodDescription = () => {
|
||||
if (seriesType === "installment") {
|
||||
return "Aplica a alteração para todas as pessoas que dividem esta parcela";
|
||||
}
|
||||
return "Aplica a alteração para todas as pessoas que dividem este lançamento";
|
||||
};
|
||||
|
||||
const getAllLabel = () => {
|
||||
if (seriesType === "installment" && totalCount) {
|
||||
return `Todas as parcelas (${totalCount} ${
|
||||
@@ -116,10 +133,10 @@ export function BulkActionDialog({
|
||||
htmlFor="period"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
{`Todas as pessoas desta parcela (${currentNumber}/${totalCount})`}
|
||||
{getPeriodLabel()}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica a alteração para todas as pessoas que dividem esta parcela
|
||||
{getPeriodDescription()}
|
||||
</p>
|
||||
{scope === "period" && actionType === "edit" && (
|
||||
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
import {
|
||||
currencyFormatter,
|
||||
formatCondition,
|
||||
formatDate,
|
||||
formatPeriod,
|
||||
} from "@/features/transactions/lib/formatting-helpers";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
|
||||
import { parseLocalDateString } from "@/shared/utils/date";
|
||||
import { formatDate, parseLocalDateString } from "@/shared/utils/date";
|
||||
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import { AttachmentSection } from "../attachments/attachment-section";
|
||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||
@@ -55,10 +55,9 @@ export function TransactionDetailsDialog({
|
||||
}: TransactionDetailsDialogProps) {
|
||||
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
|
||||
useEffect(() => {
|
||||
setAttachmentCount(null);
|
||||
}, [transaction?.id]);
|
||||
}, []);
|
||||
|
||||
if (!transaction) return null;
|
||||
|
||||
@@ -87,11 +86,16 @@ export function TransactionDetailsDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{transaction.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatDate(transaction.purchaseDate)}
|
||||
</DialogDescription>
|
||||
<DialogHeader className="text-left">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<EstablishmentLogo size={40} name={transaction.name} />
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">{transaction.name}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{formatDate(transaction.purchaseDate)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getCategoryBudgetSummaryAction } from "@/features/budgets/actions";
|
||||
import type { CategoryBudgetSummary } from "@/features/budgets/queries";
|
||||
import { TRANSACTION_TYPES } from "@/features/transactions/lib/constants";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import {
|
||||
CategorySelectContent,
|
||||
@@ -18,6 +22,22 @@ import {
|
||||
} from "../../select-items";
|
||||
import type { CategorySectionProps } from "./transaction-dialog-types";
|
||||
|
||||
const BUDGET_DANGER_RATIO = 1;
|
||||
const BUDGET_WARNING_RATIO = 0.8;
|
||||
|
||||
const getBudgetTone = (ratio: number) => {
|
||||
if (ratio >= BUDGET_DANGER_RATIO) return "text-red-600 dark:text-red-400";
|
||||
if (ratio >= BUDGET_WARNING_RATIO)
|
||||
return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
};
|
||||
|
||||
const formatCompactCurrency = (value: number) =>
|
||||
formatCurrency(value, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export function CategorySection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
@@ -28,6 +48,62 @@ export function CategorySection({
|
||||
}: CategorySectionProps) {
|
||||
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
|
||||
|
||||
const [budgetSummary, setBudgetSummary] =
|
||||
useState<CategoryBudgetSummary | null>(null);
|
||||
const cacheRef = useRef<Map<string, CategoryBudgetSummary | null>>(new Map());
|
||||
|
||||
const { categoryId, period, transactionType } = formState;
|
||||
const shouldFetchBudget =
|
||||
Boolean(categoryId) && Boolean(period) && transactionType === "Despesa";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBudget || !categoryId || !period) {
|
||||
setBudgetSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${categoryId}::${period}`;
|
||||
const cached = cacheRef.current.get(key);
|
||||
if (cached !== undefined) {
|
||||
setBudgetSummary((prev) => (prev === cached ? prev : cached));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
getCategoryBudgetSummaryAction({ categoryId, period }).then((result) => {
|
||||
if (cancelled) return;
|
||||
const data = result.success ? (result.data ?? null) : null;
|
||||
cacheRef.current.set(key, data);
|
||||
setBudgetSummary(data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shouldFetchBudget, categoryId, period]);
|
||||
|
||||
const renderBudgetBadge = () => {
|
||||
if (showTransactionTypeField) return null;
|
||||
if (!shouldFetchBudget || !budgetSummary) return null;
|
||||
|
||||
const { amount, spent } = budgetSummary;
|
||||
const ratio = amount > 0 ? spent / amount : 0;
|
||||
const percent = amount > 0 ? Math.round(ratio * 100) : 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={`${formatCurrency(spent)} de ${formatCurrency(amount)} (${percent}%)`}
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold leading-none whitespace-nowrap font-mono",
|
||||
getBudgetTone(ratio),
|
||||
)}
|
||||
>
|
||||
{formatCompactCurrency(spent)} de {formatCompactCurrency(amount)}
|
||||
<span className="ml-1 opacity-70">({percent}%)</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
{showTransactionTypeField ? (
|
||||
@@ -77,12 +153,16 @@ export function CategorySection({
|
||||
const selectedOption = categoryOptions.find(
|
||||
(opt) => opt.value === formState.categoryId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<CategorySelectContent
|
||||
label={selectedOption.label}
|
||||
icon={selectedOption.icon}
|
||||
/>
|
||||
) : null;
|
||||
if (!selectedOption) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<CategorySelectContent
|
||||
label={selectedOption.label}
|
||||
icon={selectedOption.icon}
|
||||
/>
|
||||
{renderBudgetBadge()}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { RiSliceFill } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -11,10 +13,124 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/shared/components/ui/toggle-group";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDecimalForDbRequired,
|
||||
normalizeDecimalInput,
|
||||
} from "@/shared/utils/currency";
|
||||
import { safeToNumber } from "@/shared/utils/number";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { PayerSelectContent } from "../../select-items";
|
||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
type SplitInputMode = "currency" | "percentage";
|
||||
|
||||
const SPLIT_MODE_OPTIONS = [
|
||||
{ value: "currency", label: "R$" },
|
||||
{ value: "percentage", label: "%" },
|
||||
] as const satisfies ReadonlyArray<{ value: SplitInputMode; label: string }>;
|
||||
|
||||
const amountToPercent = (amount: string, total: number): string => {
|
||||
if (total <= 0) return "";
|
||||
const numeric = safeToNumber(normalizeDecimalInput(amount), Number.NaN);
|
||||
if (!Number.isFinite(numeric)) return "";
|
||||
const pct = (numeric / total) * 100;
|
||||
return (Math.round(pct * 10) / 10).toString();
|
||||
};
|
||||
|
||||
const percentToAmount = (percent: string, total: number): string => {
|
||||
const pct = safeToNumber(normalizeDecimalInput(percent), Number.NaN);
|
||||
if (!Number.isFinite(pct) || total <= 0) return "0.00";
|
||||
const clamped = Math.min(100, Math.max(0, pct));
|
||||
return formatDecimalForDbRequired((total * clamped) / 100);
|
||||
};
|
||||
|
||||
function SplitModeToggle({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: SplitInputMode;
|
||||
onModeChange: (mode: SplitInputMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (value) onModeChange(value as SplitInputMode);
|
||||
}}
|
||||
aria-label="Modo de entrada do split"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{SPLIT_MODE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="px-2 py-0 h-7 text-xs"
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitAmountField({
|
||||
mode,
|
||||
value,
|
||||
totalAmount,
|
||||
onAmountChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
mode: SplitInputMode;
|
||||
value: string;
|
||||
totalAmount: number;
|
||||
onAmountChange: (amount: string) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
if (mode === "currency") {
|
||||
return (
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onValueChange={onAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[45%] space-y-1">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={amountToPercent(value, totalAmount)}
|
||||
onChange={(event) => {
|
||||
const sanitized = event.target.value.replace(/[^\d.,]/g, "");
|
||||
onAmountChange(percentToAmount(sanitized, totalAmount));
|
||||
}}
|
||||
placeholder="0"
|
||||
aria-label={ariaLabel}
|
||||
className="h-9 w-full pr-7 text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<p className="ml-1 text-xs text-muted-foreground">
|
||||
{formatCurrency(safeToNumber(value))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayerSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
@@ -22,17 +138,17 @@ export function PayerSection({
|
||||
secondaryPayerOptions,
|
||||
totalAmount,
|
||||
}: PayerSectionProps) {
|
||||
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
|
||||
|
||||
const handlePrimaryAmountChange = (value: string) => {
|
||||
onFieldChange("primarySplitAmount", value);
|
||||
const numericValue = Number.parseFloat(value) || 0;
|
||||
const remaining = Math.max(0, totalAmount - numericValue);
|
||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||
};
|
||||
|
||||
const handleSecondaryAmountChange = (value: string) => {
|
||||
onFieldChange("secondarySplitAmount", value);
|
||||
const numericValue = Number.parseFloat(value) || 0;
|
||||
const remaining = Math.max(0, totalAmount - numericValue);
|
||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||
};
|
||||
|
||||
@@ -54,23 +170,28 @@ export function PayerSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxPrimitive.Root
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
formState.isSplit
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input dark:bg-input/30",
|
||||
)}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||
<RiSliceFill className="size-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
<div className="flex items-center gap-2">
|
||||
{formState.isSplit ? (
|
||||
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
|
||||
) : null}
|
||||
<CheckboxPrimitive.Root
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
formState.isSplit
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input dark:bg-input/30",
|
||||
)}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||
<RiSliceFill className="size-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
@@ -111,14 +232,15 @@ export function PayerSection({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.isSplit && (
|
||||
<CurrencyInput
|
||||
{formState.isSplit ? (
|
||||
<SplitAmountField
|
||||
mode={splitMode}
|
||||
value={formState.primarySplitAmount}
|
||||
onValueChange={handlePrimaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
totalAmount={totalAmount}
|
||||
onAmountChange={handlePrimaryAmountChange}
|
||||
ariaLabel="Porcentagem da pessoa"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,11 +285,12 @@ export function PayerSection({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CurrencyInput
|
||||
<SplitAmountField
|
||||
mode={splitMode}
|
||||
value={formState.secondarySplitAmount}
|
||||
onValueChange={handleSecondaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
totalAmount={totalAmount}
|
||||
onAmountChange={handleSecondaryAmountChange}
|
||||
ariaLabel="Porcentagem do segundo pagador"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createTransactionAction,
|
||||
@@ -65,6 +65,7 @@ export function TransactionDialog({
|
||||
estabelecimentos,
|
||||
transaction,
|
||||
defaultPeriod,
|
||||
defaultAccountId,
|
||||
defaultCardId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -88,6 +89,7 @@ export function TransactionDialog({
|
||||
|
||||
const [formState, setFormState] = useState<FormState>(() =>
|
||||
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
|
||||
defaultAccountId,
|
||||
defaultCardId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -102,6 +104,8 @@ export function TransactionDialog({
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||
const [extrasOpen, setExtrasOpen] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
@@ -110,6 +114,7 @@ export function TransactionDialog({
|
||||
defaultPayerId,
|
||||
defaultPeriod,
|
||||
{
|
||||
defaultAccountId,
|
||||
defaultCardId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -142,12 +147,14 @@ export function TransactionDialog({
|
||||
setPendingFiles([]);
|
||||
setPendingDetachIds([]);
|
||||
setPendingUploadFiles([]);
|
||||
setExtrasOpen(initial.condition !== "À vista");
|
||||
}
|
||||
}, [
|
||||
dialogOpen,
|
||||
transaction,
|
||||
defaultPayerId,
|
||||
defaultPeriod,
|
||||
defaultAccountId,
|
||||
defaultCardId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -211,6 +218,22 @@ export function TransactionDialog({
|
||||
});
|
||||
}
|
||||
|
||||
function handleExtrasOpenChange(nextOpen: boolean) {
|
||||
setExtrasOpen(nextOpen);
|
||||
|
||||
if (nextOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
@@ -308,6 +331,12 @@ export function TransactionDialog({
|
||||
formState.condition === "Parcelado" && formState.installmentCount
|
||||
? Number(formState.installmentCount)
|
||||
: undefined,
|
||||
startInstallment:
|
||||
mode === "create" &&
|
||||
formState.condition === "Parcelado" &&
|
||||
formState.startInstallment
|
||||
? Number(formState.startInstallment)
|
||||
: undefined,
|
||||
recurrenceCount:
|
||||
formState.condition === "Recorrente" && formState.recurrenceCount
|
||||
? Number(formState.recurrenceCount)
|
||||
@@ -527,18 +556,21 @@ export function TransactionDialog({
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden p-4 sm:p-10">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="flex min-w-0 flex-col gap-0"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
|
||||
>
|
||||
{/* Detalhes */}
|
||||
<div className="space-y-3">
|
||||
<BasicFieldsSection
|
||||
@@ -634,7 +666,8 @@ export function TransactionDialog({
|
||||
</>
|
||||
) : (
|
||||
<Collapsible
|
||||
defaultOpen={formState.condition !== "À vista"}
|
||||
open={extrasOpen}
|
||||
onOpenChange={handleExtrasOpenChange}
|
||||
className="min-w-0"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||
@@ -680,7 +713,7 @@ export function TransactionDialog({
|
||||
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogFooter className="mt-4 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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";
|
||||
@@ -164,16 +164,14 @@ export function AnticipationCard({
|
||||
onClick={handleViewLancamento}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RiEyeLine className="mr-2 size-4" />
|
||||
Ver Lançamento
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
{canCancel && (
|
||||
<ConfirmActionDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" disabled={isPending}>
|
||||
<RiCloseLine className="mr-2 size-4" />
|
||||
Cancelar Antecipação
|
||||
Desfazer Antecipação
|
||||
</Button>
|
||||
}
|
||||
title="Cancelar antecipação?"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiExpandUpDownLine,
|
||||
RiFilter3Line,
|
||||
RiFilterLine,
|
||||
} from "@remixicon/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ReadonlyURLSearchParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from "next/navigation";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -16,11 +21,14 @@ import {
|
||||
useTransition,
|
||||
} from "react";
|
||||
import {
|
||||
AMOUNT_MAX_PARAM,
|
||||
AMOUNT_MIN_PARAM,
|
||||
PAYMENT_METHODS,
|
||||
SETTLED_FILTER_VALUES,
|
||||
TRANSACTION_CONDITIONS,
|
||||
TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/lib/constants";
|
||||
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
@@ -70,6 +78,36 @@ import type {
|
||||
|
||||
const FILTER_EMPTY_VALUE = "__all";
|
||||
|
||||
const normalizeAmountParam = (raw: string): string | null => {
|
||||
const parsed = parsePositiveAmount(raw.trim());
|
||||
return parsed === null ? null : parsed.toString();
|
||||
};
|
||||
|
||||
function useDebouncedAmountFilter(
|
||||
param: string,
|
||||
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
||||
onChange: (key: string, value: string | null) => void,
|
||||
): [string, (value: string) => void] {
|
||||
const current = searchParams.get(param) ?? "";
|
||||
const [value, setValue] = useState(current);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(current);
|
||||
}, [current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === current) return;
|
||||
const timeout = setTimeout(() => {
|
||||
const normalized = normalizeAmountParam(value);
|
||||
if ((normalized ?? "") === current) return;
|
||||
onChange(param, normalized);
|
||||
}, 400);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value, current, param, onChange]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
interface FilterSelectProps {
|
||||
param: string;
|
||||
placeholder: string;
|
||||
@@ -348,6 +386,7 @@ export function TransactionsFilters({
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
: pathname;
|
||||
router.replace(target, { scroll: false });
|
||||
router.refresh();
|
||||
});
|
||||
},
|
||||
[searchParams, pathname, router],
|
||||
@@ -373,6 +412,17 @@ export function TransactionsFilters({
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchValue, currentSearchParam, handleFilterChange]);
|
||||
|
||||
const [valorMinValue, setValorMinValue] = useDebouncedAmountFilter(
|
||||
AMOUNT_MIN_PARAM,
|
||||
searchParams,
|
||||
handleFilterChange,
|
||||
);
|
||||
const [valorMaxValue, setValorMaxValue] = useDebouncedAmountFilter(
|
||||
AMOUNT_MAX_PARAM,
|
||||
searchParams,
|
||||
handleFilterChange,
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
const periodValue = searchParams.get("periodo");
|
||||
const pageSizeValue = searchParams.get("pageSize");
|
||||
@@ -384,6 +434,8 @@ export function TransactionsFilters({
|
||||
nextParams.set("pageSize", pageSizeValue);
|
||||
}
|
||||
setSearchValue("");
|
||||
setValorMinValue("");
|
||||
setValorMaxValue("");
|
||||
startTransition(() => {
|
||||
const target = nextParams.toString()
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
@@ -467,7 +519,9 @@ export function TransactionsFilters({
|
||||
searchParams.getAll("accountCard").length > 0 ||
|
||||
searchParams.get("settled") ||
|
||||
searchParams.get("hasAttachment") ||
|
||||
searchParams.get("isDivided");
|
||||
searchParams.get("isDivided") ||
|
||||
searchParams.get(AMOUNT_MIN_PARAM) ||
|
||||
searchParams.get(AMOUNT_MAX_PARAM);
|
||||
|
||||
const handleResetFilters = () => {
|
||||
handleReset();
|
||||
@@ -523,13 +577,27 @@ export function TransactionsFilters({
|
||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||
aria-label="Abrir filtros"
|
||||
>
|
||||
<RiFilter3Line className="size-4" />
|
||||
<RiFilterLine className="size-4" />
|
||||
Filtros
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isPending}
|
||||
aria-label="Limpar filtros"
|
||||
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
|
||||
>
|
||||
<RiCloseLine className="size-3.5" />
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Filtros</DrawerTitle>
|
||||
@@ -636,6 +704,37 @@ export function TransactionsFilters({
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Status</p>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -174,7 +174,7 @@ export function TransactionsTable({
|
||||
: getPaginationRowModel(),
|
||||
manualPagination: isServerPaginated,
|
||||
pageCount: serverPagination?.totalPages,
|
||||
enableRowSelection: true,
|
||||
enableRowSelection: (row) => !row.original.readonly,
|
||||
});
|
||||
|
||||
const rowModel = table.getRowModel();
|
||||
|
||||
@@ -30,3 +30,6 @@ export const SETTLED_FILTER_VALUES = {
|
||||
PAID: "pago",
|
||||
UNPAID: "nao-pago",
|
||||
} as const;
|
||||
|
||||
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||
|
||||
@@ -9,6 +9,8 @@ type TransactionExportFilters = {
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | 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") {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { and, eq, ilike, inArray, isNotNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import {
|
||||
cards,
|
||||
type categories,
|
||||
@@ -10,12 +20,20 @@ import {
|
||||
} from "@/db/schema";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import {
|
||||
AMOUNT_MAX_PARAM,
|
||||
AMOUNT_MIN_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,
|
||||
@@ -46,6 +64,8 @@ export type TransactionSearchFilters = {
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
};
|
||||
|
||||
type BaseSluggedOption = {
|
||||
@@ -135,6 +155,13 @@ export const getMultiParam = (
|
||||
return list.filter((item): item is string => Boolean(item));
|
||||
};
|
||||
|
||||
export const parsePositiveAmount = (value: string | null): number | null => {
|
||||
if (!value) return null;
|
||||
const normalized = Number.parseFloat(value.replace(",", "."));
|
||||
if (!Number.isFinite(normalized) || normalized < 0) return null;
|
||||
return Math.round(normalized * 100) / 100;
|
||||
};
|
||||
|
||||
export const extractTransactionSearchFilters = (
|
||||
params: ResolvedSearchParams,
|
||||
): TransactionSearchFilters => ({
|
||||
@@ -148,6 +175,12 @@ export const extractTransactionSearchFilters = (
|
||||
settledFilter: getSingleParam(params, "settled"),
|
||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||
dividedFilter: getSingleParam(params, "isDivided"),
|
||||
amountMinFilter: parsePositiveAmount(
|
||||
getSingleParam(params, AMOUNT_MIN_PARAM),
|
||||
),
|
||||
amountMaxFilter: parsePositiveAmount(
|
||||
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||
),
|
||||
});
|
||||
|
||||
export const resolveTransactionPagination = (
|
||||
@@ -442,6 +475,18 @@ export const buildTransactionWhere = ({
|
||||
where.push(eq(transactions.isDivided, true));
|
||||
}
|
||||
|
||||
if (filters.amountMinFilter !== null) {
|
||||
where.push(
|
||||
gte(sql`abs(${transactions.amount})`, filters.amountMinFilter.toFixed(2)),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.amountMaxFilter !== null) {
|
||||
where.push(
|
||||
lte(sql`abs(${transactions.amount})`, filters.amountMaxFilter.toFixed(2)),
|
||||
);
|
||||
}
|
||||
|
||||
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||
if (searchPattern) {
|
||||
where.push(
|
||||
@@ -512,8 +557,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)) {
|
||||
|
||||
@@ -11,6 +11,20 @@ type CalculatorDisplayProps = {
|
||||
isResultView: boolean;
|
||||
};
|
||||
|
||||
const getExpressionSizeClass = (length: number, compact: boolean) => {
|
||||
if (compact) {
|
||||
if (length <= 14) return "text-2xl";
|
||||
if (length <= 20) return "text-xl";
|
||||
if (length <= 28) return "text-base";
|
||||
return "text-sm";
|
||||
}
|
||||
if (length <= 12) return "text-3xl";
|
||||
if (length <= 18) return "text-2xl";
|
||||
if (length <= 24) return "text-xl";
|
||||
if (length <= 32) return "text-base";
|
||||
return "text-sm";
|
||||
};
|
||||
|
||||
export function CalculatorDisplay({
|
||||
history,
|
||||
expression,
|
||||
@@ -19,8 +33,10 @@ export function CalculatorDisplay({
|
||||
onCopy,
|
||||
isResultView,
|
||||
}: CalculatorDisplayProps) {
|
||||
const sizeClass = getExpressionSizeClass(expression.length, isResultView);
|
||||
|
||||
return (
|
||||
<div className="flex h-24 flex-col rounded-xl border bg-muted px-4 py-4 text-right">
|
||||
<div className="flex h-24 min-w-0 flex-col rounded-xl border bg-muted px-4 py-4 text-right">
|
||||
<div className="min-h-5 truncate text-sm text-muted-foreground">
|
||||
{history ?? (
|
||||
<span
|
||||
@@ -31,11 +47,11 @@ export function CalculatorDisplay({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-end justify-end gap-2">
|
||||
<div className="mt-auto flex min-w-0 items-end justify-end gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-right font-semibold transition-all",
|
||||
isResultView ? "text-2xl" : "text-3xl",
|
||||
"min-w-0 flex-1 truncate text-right font-semibold transition-all",
|
||||
sizeClass,
|
||||
)}
|
||||
>
|
||||
{expression}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -87,7 +87,7 @@ function Calendar({
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -172,8 +172,8 @@ export function DatePicker({
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={handleCalendarSelect}
|
||||
fromYear={2020}
|
||||
toYear={new Date().getFullYear() + 10}
|
||||
startMonth={new Date(2020, 0)}
|
||||
endMonth={new Date(new Date().getFullYear() + 10, 11)}
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
sql,
|
||||
sum,
|
||||
} from "drizzle-orm";
|
||||
import { cards, transactions } from "@/db/schema";
|
||||
import { cards, financialAccounts, transactions } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
@@ -96,12 +97,17 @@ export async function fetchPayerMonthlyBreakdown({
|
||||
totalAmount: sum(transactions.amount).as("total"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.payerId, payerId),
|
||||
eq(transactions.period, period),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.paymentMethod, transactions.transactionType);
|
||||
@@ -155,6 +161,10 @@ export async function fetchPayerHistory({
|
||||
totalAmount: sum(transactions.amount).as("total"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -162,6 +172,7 @@ export async function fetchPayerHistory({
|
||||
gte(transactions.period, start),
|
||||
lte(transactions.period, end),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.period, transactions.transactionType);
|
||||
@@ -210,6 +221,10 @@ export async function fetchPayerCardUsage({
|
||||
})
|
||||
.from(transactions)
|
||||
.innerJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -217,6 +232,7 @@ export async function fetchPayerCardUsage({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_CARD),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.cardId, cards.name, cards.logo);
|
||||
@@ -251,6 +267,10 @@ export async function fetchPayerBoletoStats({
|
||||
totalCount: sql<number>`count(${transactions.id})`.as("count"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -258,6 +278,7 @@ export async function fetchPayerBoletoStats({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.isSettled);
|
||||
@@ -303,6 +324,10 @@ export async function fetchPayerBoletoItems({
|
||||
isSettled: transactions.isSettled,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -310,6 +335,7 @@ export async function fetchPayerBoletoItems({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(transactions.dueDate));
|
||||
@@ -343,6 +369,10 @@ export async function fetchPayerPaymentStatus({
|
||||
pendingCount: sql<number>`sum(case when (${transactions.isSettled} = false or ${transactions.isSettled} is null) then 1 else 0 end)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -350,6 +380,7 @@ export async function fetchPayerPaymentStatus({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.transactionType, DESPESA),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user