Compare commits

..

15 Commits

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

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

View File

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

View File

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

32
.vscode/settings.json vendored
View File

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

View File

@@ -5,6 +5,59 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.6.1] - 2026-05-21
Esta versão corrige o pipeline de publicação após o salto para a `2.6.0`. O build do GitHub Actions falhava antes mesmo de instalar as dependências porque o workflow ainda fixava uma versão antiga do `pnpm`, enquanto o projeto já declarava `pnpm@11.1.3` no `packageManager`.
### Corrigido
- CI: o workflow de build deixou de fixar uma versão diferente do `pnpm`, usando a versão declarada em `packageManager` para evitar conflito no `pnpm/action-setup`.
- CI: a política de builds do `pnpm` foi migrada para `allowBuilds`, permitindo explicitamente os scripts necessários de `core-js`, `esbuild`, `sharp` e `unrs-resolver` durante o install no GitHub Actions.
## [2.6.0] - 2026-05-21
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.
### Adicionado
- Autenticação: nova variável `DISABLE_SIGNUP=true` para bloquear novos cadastros. Quando ativa, a tela de cadastro deixa de aparecer na navegação, `/signup` redireciona para login/dashboard e a API de signup responde `403`.
- Lançamentos: compras parceladas agora podem começar em uma parcela intermediária, como `5 de 10`. O sistema gera apenas as parcelas restantes e preserva o cálculo do valor unitário com base no total original.
- Logos: adicionado o logo da Bipa à biblioteca local de marcas.
- Relatórios: a análise de parcelas agora separa parcelas acompanhadas daquelas que ficaram fora do acompanhamento quando o parcelamento começa no meio da série.
### Alterado
- Contas: a página de extrato em `/accounts/[accountId]` voltou a exibir os botões "Nova Receita" e "Nova Despesa", alinhando o fluxo com as demais telas de lançamentos.
- Cartões: os cards de `/cards` agora mostram o valor da fatura do mês atual junto dos indicadores de limite. O limite utilizado passa a considerar faturas em aberto, não apenas o status interno do lançamento.
- Lançamentos: ao criar um lançamento a partir do extrato de uma conta, o diálogo já abre com essa conta selecionada como destino padrão.
- Importação: os controles globais da revisão de extrato foram realinhados à esquerda, com espaçamento mais compacto e larguras mais consistentes.
### Corrigido
- Dashboard: o widget "Status de Pagamento" voltou a mostrar corretamente os valores em "A Pagar", somando despesas pelo valor absoluto e mantendo reembolsos como abatimento.
- Importação: datas vindas de planilhas agora preservam o dia informado no Excel, evitando que `20/05/2026` apareça como `19/05/2026` em fusos como `America/Sao_Paulo`.
- Importação: o seletor de categoria por linha agora mostra apenas categorias compatíveis com o tipo detectado do lançamento, separando receitas e despesas durante a revisão do extrato.
- Importação: cada linha da revisão de extrato agora permite escolher uma pessoa específica, enquanto o campo global continua servindo como atalho para aplicar a pessoa nos lançamentos selecionados.
- Lançamentos: despesas comuns na categoria `Pagamentos` voltaram a poder ser editadas, removidas, copiadas e importadas. A proteção continua valendo apenas para pagamentos automáticos de fatura com nota técnica `AUTO_FATURA:`.
### Dependências
- Stack core: `pnpm` 10.33.0 → 11.1.3.
- Auth: `better-auth` e `@better-auth/passkey` 1.6.10 → 1.6.11.
- AI SDKs: `@ai-sdk/anthropic` 3.0.76 → 3.0.78, `@ai-sdk/google` 3.0.71 → 3.0.75, `@ai-sdk/openai` 3.0.63 → 3.0.64 e `ai` 6.0.177 → 6.0.185.
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1045.0 → 3.1050.0.
- UI e dados: `@tanstack/react-query` 5.100.9 → 5.100.11, `date-fns` 4.1.0 → 4.2.1, `jspdf-autotable` 5.0.7 → 5.0.8, `pg` 8.20.0 → 8.21.0 e `react-day-picker` 10.0.0 → 10.0.1.
- Dev tooling: `@types/node` 25.6.2 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `knip` 6.12.2 → 6.14.1, `tsx` 4.21.0 → 4.22.3 e novo `babel-plugin-react-compiler` 1.0.0.
## [2.5.7] - 2026-05-14
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
### Alterado
- Relatórios: em `/reports/installment-analysis`, os cards de parcelas passam a usar o logo do estabelecimento como avatar principal; o logo do cartão agora aparece menor ao lado do nome do cartão, tanto no card quanto no modal de detalhes.
- Relatórios: a página de análise de parcelas pré-carrega os mapeamentos de logos de estabelecimentos para evitar troca visual após o primeiro render.
- Lançamentos: o campo de anexos no modal agora aceita arquivos colados com `Ctrl+V`, mantendo o botão para buscar arquivos normalmente.
- Lançamentos: o modal agora usa uma única área interna de rolagem, com cabeçalho e rodapé estáveis, reduzindo travadas ao rolar e ao abrir "Condições, anotações e anexos".
- Anotações: tarefas agora podem ser editadas inline no modal "Atualizar anotação"; clicar no texto abre o input e o botão de remover vira botão de salvar naquela linha.
### Corrigido
- Relatórios: o join com cartões na análise de parcelas agora também valida `cards.userId`, mantendo o filtro de ownership explícito na consulta.
## [2.5.6] - 2026-05-07 ## [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. Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.5.6-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.6.1-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -127,10 +127,11 @@ Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar
# 1. Baixe o compose # 1. Baixe o compose
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml 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 # .env mínimo recomendado para produção
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32 BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
BETTER_AUTH_URL=http://seu-dominio.com BETTER_AUTH_URL=http://seu-dominio.com
DISABLE_SIGNUP=false # opcional: true bloqueia novos cadastros
# 3. Suba tudo # 3. Suba tudo
docker compose up -d docker compose up -d
@@ -443,6 +444,9 @@ POSTGRES_USER=openmonetis
POSTGRES_PASSWORD=openmonetis_dev_password POSTGRES_PASSWORD=openmonetis_dev_password
POSTGRES_DB=openmonetis_db POSTGRES_DB=openmonetis_db
# Autenticação
DISABLE_SIGNUP=false # true bloqueia novos cadastros
# S3 Server (opcional, necessario para anexos) # S3 Server (opcional, necessario para anexos)
S3_ENDPOINT= S3_ENDPOINT=
S3_REGION= S3_REGION=

View File

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

View File

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

3965
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

BIN
public/logos/bipa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,9 +41,9 @@
--input: var(--border); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
--chart-1: var(--color-emerald-500); --chart-1: var(--color-orange-600);
--chart-2: var(--color-red-500); --chart-2: var(--color-orange-400);
--chart-3: var(--color-amber-500); --chart-3: var(--color-orange-200);
--chart-4: var(--color-blue-500); --chart-4: var(--color-blue-500);
--chart-5: var(--color-pink-500); --chart-5: var(--color-pink-500);
--chart-6: var(--color-stone-500); --chart-6: var(--color-stone-500);
@@ -117,13 +117,13 @@
--destructive: oklch(62% 0.2 28); --destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30); --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); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
--chart-1: var(--color-emerald-500); --chart-1: var(--color-orange-600);
--chart-2: var(--color-orange-500); --chart-2: var(--color-orange-400);
--chart-3: var(--color-indigo-500); --chart-3: var(--color-orange-200);
--chart-4: var(--color-amber-500); --chart-4: var(--color-amber-500);
--chart-5: var(--color-pink-500); --chart-5: var(--color-pink-500);
--chart-6: var(--color-stone-500); --chart-6: var(--color-stone-500);

View File

@@ -21,10 +21,18 @@ import { GoogleAuthButton } from "./google-auth-button";
type DivProps = React.ComponentProps<"div">; type DivProps = React.ComponentProps<"div">;
interface LoginFormProps extends DivProps {
signupDisabled?: boolean;
}
const authLinkClassName = 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"; "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 router = useRouter();
const isGoogleAvailable = googleSignInAvailable; const isGoogleAvailable = googleSignInAvailable;
@@ -233,12 +241,14 @@ export function LoginForm({ className, ...props }: DivProps) {
</div> </div>
</Field> </Field>
<FieldDescription className="pt-1 text-center"> {!signupDisabled && (
Não tem uma conta?{" "} <FieldDescription className="pt-1 text-center">
<a href="/signup" className={authLinkClassName}> Não tem uma conta?{" "}
Inscreva-se <a href="/signup" className={authLinkClassName}>
</a> Inscreva-se
</FieldDescription> </a>
</FieldDescription>
)}
<FieldDescription className="text-center text-sm text-muted-foreground"> <FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}> <a href="/" className={authLinkClassName}>

View File

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

View File

@@ -130,7 +130,7 @@ export function CardsPage({
} }
return ( 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) => ( {list.map((card) => (
<CardItem <CardItem
key={card.id} key={card.id}
@@ -142,6 +142,8 @@ export function CardsPage({
limit={card.limit} limit={card.limit}
limitInUse={card.limitInUse ?? null} limitInUse={card.limitInUse ?? null}
limitAvailable={card.limitAvailable ?? card.limit ?? null} limitAvailable={card.limitAvailable ?? card.limit ?? null}
currentInvoiceAmount={card.currentInvoiceAmount}
currentInvoiceLabel={card.currentInvoiceLabel}
accountName={card.accountName} accountName={card.accountName}
logo={card.logo} logo={card.logo}
note={card.note} note={card.note}

View File

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

View File

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

View File

@@ -330,7 +330,7 @@ export function DashboardGridEditable({
> >
<div className="relative"> <div className="relative">
{isEditing && ( {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"> <div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" /> <RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-medium"> <span className="text-xs font-medium">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -335,6 +335,12 @@ const baseFields = z.object({
.min(1, "Selecione uma quantidade válida.") .min(1, "Selecione uma quantidade válida.")
.max(60, "Selecione uma quantidade válida.") .max(60, "Selecione uma quantidade válida.")
.optional(), .optional(),
startInstallment: z.coerce
.number()
.int()
.min(1, "Selecione uma parcela válida.")
.max(60, "Selecione uma parcela válida.")
.optional(),
recurrenceCount: z.coerce recurrenceCount: z.coerce
.number() .number()
.int() .int()
@@ -415,6 +421,15 @@ const refineLancamento = (
path: ["installmentCount"], path: ["installmentCount"],
message: "Selecione pelo menos duas parcelas.", 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") { if (data.condition === "Parcelado") {
const installmentTotal = data.installmentCount ?? 0; const installmentTotal = data.installmentCount ?? 0;
const startInstallment = data.startInstallment ?? 1;
const amountsByShare = shares.map((share) => const amountsByShare = shares.map((share) =>
splitAmount(share.amountCents, installmentTotal), splitAmount(share.amountCents, installmentTotal),
); );
for ( for (
let installment = 0; let index = 0;
installment < installmentTotal; index <= installmentTotal - startInstallment;
installment += 1 index += 1
) { ) {
const installmentPeriod = addMonthsToPeriod(period, installment); const currentInstallment = startInstallment + index;
const installmentPeriod = addMonthsToPeriod(period, index);
const installmentDueDate = dueDate const installmentDueDate = dueDate
? addMonthsToDate(dueDate, installment) ? addMonthsToDate(dueDate, index)
: null; : null;
const splitGroupId = cycleSplitGroupId(); const splitGroupId = cycleSplitGroupId();
shares.forEach((share, shareIndex) => { shares.forEach((share, shareIndex) => {
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; const amountCents =
const settled = resolveSettledValue(installment); amountsByShare[shareIndex]?.[currentInstallment - 1] ?? 0;
const settled = resolveSettledValue(index);
records.push({ records.push({
...basePayload, ...basePayload,
amount: centsToDecimalString(amountCents * amountSign), amount: centsToDecimalString(amountCents * amountSign),
@@ -677,7 +695,7 @@ export const buildTransactionRecords = ({
period: installmentPeriod, period: installmentPeriod,
isSettled: settled, isSettled: settled,
installmentCount: installmentTotal, installmentCount: installmentTotal,
currentInstallment: installment + 1, currentInstallment,
recurrenceCount: null, recurrenceCount: null,
dueDate: installmentDueDate, dueDate: installmentDueDate,
splitGroupId, splitGroupId,

View File

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

View File

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

View File

@@ -1,13 +1,18 @@
"use client"; "use client";
import { RiAttachment2, RiCloseLine } from "@remixicon/react"; import { RiAttachment2, RiCloseLine } from "@remixicon/react";
import { useRef } from "react"; import { useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config"; } from "@/features/transactions/lib/attachments-config";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
getFilesFromClipboard,
isTextEditingTarget,
validateAttachmentFile,
} from "./attachment-file-utils";
interface AttachmentFilePickerProps { interface AttachmentFilePickerProps {
files: File[]; files: File[];
@@ -22,34 +27,54 @@ export function AttachmentFilePicker({
onRemove, onRemove,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) { }: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); 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>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0]; const selected = e.target.files?.[0];
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
if (!selected) return; if (!selected) return;
if ( addFile(selected);
!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);
} }
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 ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-medium">Anexos</p> <p className="text-xs font-medium">Anexos</p>
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
type="button" type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground" 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()} onClick={() => inputRef.current?.click()}
onPaste={handlePaste}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<RiAttachment2 className="size-4" /> <RiAttachment2 className="size-4" />
Adicionar anexo Adicionar anexo
</span> </span>
<span className="text-xs"> <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> </span>
</button> </button>
</div> </div>

View File

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

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiAttachment2 } from "@remixicon/react"; import { RiAttachment2 } from "@remixicon/react";
import { useRef, useTransition } from "react"; import { useEffect, useRef, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
@@ -11,6 +11,11 @@ import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config"; } from "@/features/transactions/lib/attachments-config";
import {
getFilesFromClipboard,
isTextEditingTarget,
validateAttachmentFile,
} from "./attachment-file-utils";
interface AttachmentUploadProps { interface AttachmentUploadProps {
transactionId: string; transactionId: string;
@@ -25,7 +30,6 @@ export function AttachmentUpload({
onPendingUpload, onPendingUpload,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentUploadProps) { }: AttachmentUploadProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -36,19 +40,13 @@ export function AttachmentUpload({
if (!file) return; if (!file) return;
if ( handleFile(file);
!ALLOWED_MIME_TYPES.includes( }
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
toast.error(
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
);
return;
}
if (file.size > maxFileSizeBytes) { function handleFile(file: File) {
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`); const validation = validateAttachmentFile(file, maxSizeMb);
if (!validation.ok) {
toast.error(validation.error);
return; 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 ( return (
<> <>
<input <input
@@ -107,6 +128,7 @@ export function AttachmentUpload({
type="button" type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50" 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()} onClick={() => inputRef.current?.click()}
onPaste={handlePaste}
disabled={isPending} disabled={isPending}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -115,7 +137,8 @@ export function AttachmentUpload({
</span> </span>
{!isPending && ( {!isPending && (
<span className="text-xs"> <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> </span>
)} )}
</button> </button>

View File

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

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { useState } from "react";
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants"; import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,6 +20,61 @@ import { cn } from "@/shared/utils/ui";
import { ConditionSelectContent } from "../../select-items"; import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./transaction-dialog-types"; 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({ export function ConditionSection({
formState, formState,
onFieldChange, onFieldChange,
@@ -37,11 +98,17 @@ export function ConditionSection({
const installmentSummary = const installmentSummary =
showInstallments && showInstallments &&
formState.installmentCount && formState.installmentCount &&
amount &&
!Number.isNaN(installmentCount) && !Number.isNaN(installmentCount) &&
installmentCount > 0 installmentCount > 0
? getInstallmentLabel(installmentCount) ? getInstallmentLabel(installmentCount)
: null; : null;
const startInstallmentOptions =
showInstallments &&
formState.installmentCount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? Array.from({ length: installmentCount }, (_, index) => index + 1)
: [];
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="flex w-full flex-col gap-2 md:flex-row">
@@ -96,6 +163,11 @@ export function ConditionSection({
})} })}
</SelectContent> </SelectContent>
</Select> </Select>
<InlineStartInstallmentPicker
value={formState.startInstallment}
options={startInstallmentOptions}
onChange={(value) => onFieldChange("startInstallment", value)}
/>
</div> </div>
) : null} ) : null}

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiArrowDropDownLine } from "@remixicon/react"; 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 { toast } from "sonner";
import { import {
createTransactionAction, createTransactionAction,
@@ -65,6 +65,7 @@ export function TransactionDialog({
estabelecimentos, estabelecimentos,
transaction, transaction,
defaultPeriod, defaultPeriod,
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -88,6 +89,7 @@ export function TransactionDialog({
const [formState, setFormState] = useState<FormState>(() => const [formState, setFormState] = useState<FormState>(() =>
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, { buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -102,6 +104,8 @@ export function TransactionDialog({
const [pendingFiles, setPendingFiles] = useState<File[]>([]); const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]); const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]); const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
const [extrasOpen, setExtrasOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
@@ -110,6 +114,7 @@ export function TransactionDialog({
defaultPayerId, defaultPayerId,
defaultPeriod, defaultPeriod,
{ {
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -142,12 +147,14 @@ export function TransactionDialog({
setPendingFiles([]); setPendingFiles([]);
setPendingDetachIds([]); setPendingDetachIds([]);
setPendingUploadFiles([]); setPendingUploadFiles([]);
setExtrasOpen(initial.condition !== "À vista");
} }
}, [ }, [
dialogOpen, dialogOpen,
transaction, transaction,
defaultPayerId, defaultPayerId,
defaultPeriod, defaultPeriod,
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, 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>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setErrorMessage(null); setErrorMessage(null);
@@ -308,6 +331,12 @@ export function TransactionDialog({
formState.condition === "Parcelado" && formState.installmentCount formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount) ? Number(formState.installmentCount)
: undefined, : undefined,
startInstallment:
mode === "create" &&
formState.condition === "Parcelado" &&
formState.startInstallment
? Number(formState.startInstallment)
: undefined,
recurrenceCount: recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount) ? Number(formState.recurrenceCount)
@@ -527,18 +556,21 @@ export function TransactionDialog({
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null} {trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent 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> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<form <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} onSubmit={handleSubmit}
noValidate 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 */} {/* Detalhes */}
<div className="space-y-3"> <div className="space-y-3">
<BasicFieldsSection <BasicFieldsSection
@@ -634,7 +666,8 @@ export function TransactionDialog({
</> </>
) : ( ) : (
<Collapsible <Collapsible
defaultOpen={formState.condition !== "À vista"} open={extrasOpen}
onOpenChange={handleExtrasOpenChange}
className="min-w-0" className="min-w-0"
> >
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4"> <CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
@@ -680,7 +713,7 @@ export function TransactionDialog({
<p className="mt-3 text-sm text-destructive">{errorMessage}</p> <p className="mt-3 text-sm text-destructive">{errorMessage}</p>
) : null} ) : null}
<DialogFooter className="mt-4"> <DialogFooter className="mt-4 shrink-0">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,7 @@ interface TransactionsPageProps {
categoryFilterOptions: TransactionFilterOption[]; categoryFilterOptions: TransactionFilterOption[];
accountCardFilterOptions: AccountCardFilterOption[]; accountCardFilterOptions: AccountCardFilterOption[];
selectedPeriod: string; selectedPeriod: string;
defaultAccountId?: string | null;
estabelecimentos: string[]; estabelecimentos: string[];
allowCreate?: boolean; allowCreate?: boolean;
noteAsColumn?: boolean; noteAsColumn?: boolean;
@@ -96,6 +97,7 @@ export function TransactionsPage({
categoryFilterOptions, categoryFilterOptions,
accountCardFilterOptions, accountCardFilterOptions,
selectedPeriod, selectedPeriod,
defaultAccountId,
estabelecimentos, estabelecimentos,
allowCreate = true, allowCreate = true,
noteAsColumn = false, noteAsColumn = false,
@@ -562,6 +564,7 @@ export function TransactionsPage({
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
defaultCardId={defaultCardId} defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod} defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
@@ -585,6 +588,7 @@ export function TransactionsPage({
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
defaultCardId={defaultCardId} defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod} defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
@@ -648,6 +652,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined} transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}
/> />
@@ -669,6 +674,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToImport ?? undefined} transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
isImporting={true} isImporting={true}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}
/> />
@@ -697,6 +703,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
onSplitEditRequest={handleSplitEditRequest} onSplitEditRequest={handleSplitEditRequest}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react"; import { RiCalendarCheckLine } from "@remixicon/react";
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { useTransition } from "react"; import { useTransition } from "react";
@@ -164,16 +164,14 @@ export function AnticipationCard({
onClick={handleViewLancamento} onClick={handleViewLancamento}
disabled={isPending} disabled={isPending}
> >
<RiEyeLine className="mr-2 size-4" /> Cancelar
Ver Lançamento
</Button> </Button>
{canCancel && ( {canCancel && (
<ConfirmActionDialog <ConfirmActionDialog
trigger={ trigger={
<Button variant="destructive" size="sm" disabled={isPending}> <Button variant="destructive" size="sm" disabled={isPending}>
<RiCloseLine className="mr-2 size-4" /> Desfazer Antecipação
Cancelar Antecipação
</Button> </Button>
} }
title="Cancelar antecipação?" title="Cancelar antecipação?"

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,13 @@ import {
TRANSACTION_CONDITIONS, TRANSACTION_CONDITIONS,
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants"; } 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 { import {
PAYER_ROLE_ADMIN, PAYER_ROLE_ADMIN,
PAYER_ROLE_THIRD_PARTY, PAYER_ROLE_THIRD_PARTY,
@@ -551,8 +557,10 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
hasAttachments: item.hasAttachments ?? false, hasAttachments: item.hasAttachments ?? false,
readonly: readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) || Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.category?.name === "Saldo inicial" || (item.note === INITIAL_BALANCE_NOTE &&
item.category?.name === "Pagamentos", 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[]) => const sortByLabel = <T extends { label: string }>(items: T[]) =>

View File

@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/shared/lib/auth/config"; import { auth } from "@/shared/lib/auth/config";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
// Rotas protegidas que requerem autenticação // Rotas protegidas que requerem autenticação
const PROTECTED_ROUTES = [ const PROTECTED_ROUTES = [
@@ -85,6 +86,22 @@ export default async function proxy(request: NextRequest) {
}); });
const isAuthenticated = !!session?.user; 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 // Redirect authenticated users away from login/signup pages
if (isAuthenticated && PUBLIC_AUTH_ROUTES.includes(pathname)) { if (isAuthenticated && PUBLIC_AUTH_ROUTES.includes(pathname)) {

View File

@@ -36,9 +36,6 @@ export function NotificationBellTrigger({
"group relative shadow-none transition-all duration-200", "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", "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", "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 <RiNotification2Line
@@ -55,7 +52,7 @@ export function NotificationBellTrigger({
> >
{displayCount} {displayCount}
</span> </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} ) : null}
</button> </button>

View File

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

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

@@ -172,8 +172,8 @@ export function DatePicker({
month={month} month={month}
onMonthChange={setMonth} onMonthChange={setMonth}
onSelect={handleCalendarSelect} onSelect={handleCalendarSelect}
fromYear={2020} startMonth={new Date(2020, 0)}
toYear={new Date().getFullYear() + 10} endMonth={new Date(new Date().getFullYear() + 10, 11)}
locale={ptBR} locale={ptBR}
/> />
</PopoverContent> </PopoverContent>

View File

@@ -17,7 +17,7 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( 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, className,
)} )}
{...props} {...props}

View File

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

View File

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

View File

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