diff --git a/.gitignore b/.gitignore index ea3e830..c42bf1a 100644 --- a/.gitignore +++ b/.gitignore @@ -104,11 +104,10 @@ docker-compose.override.yml .claude/ .gemini/ .cursor/ -CLAUDE.md -AGENTS.md QWEN.md -claude.md -agents.md +AGENTS.md +# === Backups locais === +/backup/ # === Backups e Temporários === *.bak diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index dc0bb0f..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v22.12.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f80392..67420df 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,9 @@ }, "eslint.enable": false, "prettier.enable": false, - "typescript.preferences.organizeImportsCollation": "ordinal" + "typescript.preferences.organizeImportsCollation": "ordinal", + "editor.fontSize": 15, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index d29c360..1469e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,61 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). -## [Unreleased] +## [2.0.0] - 2026-03-21 + +### Adicionado + +- Infraestrutura: script `scripts/backup.sh` para backup automático do banco PostgreSQL; configuração de destino (rclone, cron, retenção) feita separadamente; passa a gerar também `*.data.sql.gz` com dados puros de todas as tabelas públicas (`--data-only --schema=public`) +- Importação de extratos OFX e XLS/XLSX com tela de revisão, detecção automática de categoria por histórico de uso, deduplicação por FITID e acesso direto pela tabela de transações + +### Alterado + +- Ajustes: aba de exclusão da conta passa a oferecer opção de zerar dados financeiros (preferências, tokens do Companion, compartilhamentos) sem excluir o usuário; categorias e pagador admin são recriados em seguida. +- Performance: paginação server-side real com `count`, `limit` e `offset` em transações, extrato e inbox, com sincronização de `page`, `pageSize` e `status` na URL; `fetchInboxDialogData()` restrito ao fluxo de processamento. +- Performance: dashboard reduzido de 19 fetchers para 7 blocos com agregações compartilhadas; snapshots dedicados para navbar (avatar do pagador admin, notificações, inbox) e quick actions, ambos com cache por usuário. +- Performance: exportações de lançamentos e relatório por categoria carregam `xlsx`, `jspdf` e `jspdf-autotable` sob demanda, apenas no clique. +- Performance: agregação de insights busca o pagador admin uma vez por request, remove joins repetidos com `pagadores` e paraleliza consultas independentes do período. +- Cache: invalidação do dashboard segmentada por `userId` nas server actions; `revalidateForEntity()` agora exige `userId`, sem fallback global para dashboard. +- Cache: agregação de insights com cache por usuário e período, reaproveitando a invalidação financeira segmentada. +- Arquitetura: `getAdminPayerId` adotado em contas, orçamentos, calendário, detalhe de categoria, extrato e actions, eliminando JOINs repetidos com `payers.role`. +- Banco: unique constraints compostas em `faturas` e `orcamentos`, com migration que aborta em caso de duplicatas históricas; actions tratam conflitos de concorrência com `upsert` para status de fatura e `onConflictDoNothing` para orçamentos. +- Qualidade: `pnpm run lint` e `next build` passam sem erros de TypeScript; validação de tipos ativa no build. +- Refatoração: identificadores internos migrados de PT-BR para inglês (`lancamento` → `transaction`, `pagador` → `payer`, `conta` → `account`, `cartao` → `card`, `categoria` → `category`, `orcamento` → `budget`); strings de UI permanecem em português. Search params de lançamentos também migrados (`type`, `condition`, `payment`, `payer`, `category`, `accountCard`). +- Lançamentos recorrentes: criação de todos os meses diretamente no fluxo do lançamento, com seleção explícita da quantidade de meses no formulário. +- UI: `type-badge` renomeado para `transaction-type-badge` com mapeamento centralizado por tipo financeiro; visual unificado em tabela, detalhe de transação e cabeçalho de categoria. +- UI: navbar com `dot pattern` SVG sutil sobre a cor primária, máscara horizontal e camada de luz suave; cards de login/cadastro reaproveitam a mesma linguagem visual com `dot pattern` e brilho em `primary`. +- UI: login e cadastro reequilibrados com espaçamentos mais consistentes, largura útil fixa e cabeçalhos com descrição. +- UI: labels padronizados em formulários, tabelas, relatórios e estados vazios; skeletons com cantos menos arredondados; loading da home espelha estrutura atual (boas-vindas, navegação mensal, cards de métricas e toolbar de widgets). +- Faturas: card de resumo refinado com hierarquia clara para valor, vencimento e status; metadados em blocos discretos e faixa de ação contextual para pagamento e edição de data. +- Tipografia: aplicação carrega apenas a família `America` (`regular`, `medium` e `bold`) como fonte global, removendo personalização por usuário e distinção de fonte para valores monetários. +- Pagadores: a tela de detalhe agora mantém o card principal do pagador visível durante a navegação entre abas, sem repetir o bloco completo dentro de cada seção. +- Pagadores: detalhes sensíveis como envio automático, último envio e observações agora ficam ocultos quando o acesso ao pagador é somente leitura. +- Pagadores: o e-mail do pagador agora aparece apenas no cabeçalho fixo, evitando repetição dentro do card de detalhes. +- Relatório de tendências: a tabela e os cards mobile agora exibem a média mensal do período filtrado ao lado do total, com destaque visual em azul; a coluna de categoria também ficou mais compacta com truncamento para nomes longos. +- Dashboard: o welcome banner deixou de ser um bloco colorido para virar apenas texto destacado. +- UI base: o `Card` compartilhado agora mantém a borda neutra no estado padrão e aplica um gradiente entre `border` e `primary` no hover. +- Assets: imagens que estavam soltas na raiz de `public/` foram movidas para `public/imagens/`, com atualização dos caminhos usados por landing page, logos, exports e manifesto do app. +- Dashboard: `section-cards` foi renomeado para `dashboard-metrics-cards`; `boletos-widget` renomeado para `bill-widget`; widgets componentizados internamente por domínio (`invoices/`, `bills/`, `notes/`, `goals-progress/`, `payment-overview/`, `installment-expenses/`). +- Widgets: `widget-card` foi separado entre um card base e uma versão expansível, isolando a lógica de overflow sem alterar o visual atual dos widgets. +- Datas: helpers de `YYYY-MM-DD`, labels de vencimento/pagamento e o relógio de negócio foram centralizados em `lib/utils/date.ts`, reduzindo drift de timezone em dashboard, pagadores, calendário, exports e actions. +- Lançamentos: a tabela deixou de quebrar ao formatar datas inválidas ou serializadas como ISO completo, normalizando `purchaseDate` para `YYYY-MM-DD` com fallback seguro. +- Logos e cartões: resolução de logos e brand assets foi consolidada em `lib/logo/index.ts` e `lib/cartoes/brand-assets.ts`, com adoção em cartões, contas, notificações, inbox, relatórios e seletores. + +### Corrigido + +- Relatório de tendências: a coluna Média agora considera apenas os meses com gastos registrados (valores > 0), ignorando meses sem movimentação no cálculo +- Dashboard: ícones de seta nos cards de métricas (receita/despesa) estavam invertidos; cor do card de saldo ajustada para `cyan-600` +- Landing page: gradiente sobreposto removido da hero section +- Lançamentos: o schema compartilhado de observação voltou a aceitar `null`, corrigindo o erro `Invalid input: expected string, received null` ao salvar novos lançamentos sem anotação. +- Cartões/Faturas: o pagamento da fatura passou a usar o valor líquido do período no cartão, evitando que o extrato da conta registre o total bruto das despesas quando houver receitas como estornos ou créditos na mesma fatura. +- Hooks e sincronização: o provider de privacidade voltou a reagir corretamente às mudanças do modo privado, e o resumo de fatura agora reseta a data de pagamento quando a prop inicial deixa de existir. +- Compatibilidade da refatoração de hooks e relatórios: `useMobile`/`useIsMobile` voltaram a ter exports compatíveis, o shim de `components/ui/use-mobile.ts` foi restaurado para o sidebar e `lib/relatorios/types.ts` voltou a reexportar os tipos usados pelos fetchers legados. +- Widgets expansíveis: o shell compartilhado voltou a aplicar `relative` e `overflow-hidden`, mantendo o gradiente e o botão "Ver tudo" presos ao card. +- Dashboard: o widget "Lançamentos por categoria" deixou de ler a categoria salva no `sessionStorage` durante a renderização inicial, evitando mismatch de hidratação entre servidor e cliente. + +### Removido + +- Dashboard/Ajustes: toda a implementação legada de `magnet-lines` foi removida, incluindo componente órfão, preferência de usuário e a coluna `disable_magnetlines` do schema com migration dedicada. ## [1.7.7] - 2026-03-05 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0ade71b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,318 @@ +# CLAUDE.md - OpenMonetis + +> Self-hosted personal finance app (Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind 4, shadcn/ui). +> Portuguese UI, English folders/imports. Linter: Biome 2.x. Package manager: pnpm. + +## Related Projects + +- **OpenMonetis Companion** (`~/github/openmonetis-companion`): Android app que captura notificacoes de apps bancarios e envia para o OpenMonetis via API. Os itens chegam na feature `inbox` para revisao. + +--- + +## Critical Rules + +1. **Sempre filtrar por `userId`** em queries. +2. **Usar `getAdminPayerId(userId)`** de `src/shared/lib/payers/get-admin-id.ts` ao inves de JOIN com `payers` para descobrir o admin. +3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. +4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. +5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. +6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog. +7. **Comunicacao**: responder em portugues clara e direta com o time. + +--- + +## Architecture + +### Feature-First + +- `src/app/`: roteamento, layouts, loading states e paginas finas +- `src/features/`: codigo de dominio por feature +- `src/shared/`: tudo que e genuinamente reutilizado entre features +- `src/db/`: schema do banco + +### Regra Feature vs Shared + +Use esta pergunta: + +> Se eu deletar esta feature, este arquivo deveria sumir junto? + +- Sim: vai para `src/features//` +- Nao: vai para `src/shared/` + +### Features nao importam outras features + +Se um contrato cruza dominios, ele deve morar em `src/shared/`. + +Exemplos comuns: + +- auth: `src/shared/lib/auth/*` +- db: `src/shared/lib/db.ts` +- revalidation helpers: `src/shared/lib/actions/*` +- payers cross-domain helpers: `src/shared/lib/payers/*` +- period/currency/date: `src/shared/utils/*` +- shadcn/ui: `src/shared/components/ui/*` + +--- + +## Directory Structure + +```text +src/ +├── app/ +│ ├── (auth)/ +│ │ ├── login/page.tsx +│ │ └── signup/page.tsx +│ ├── (dashboard)/ +│ │ ├── dashboard/ +│ │ ├── transactions/ +│ │ ├── cards/ +│ │ │ └── [cardId]/invoice/ +│ │ ├── accounts/ +│ │ │ └── [accountId]/statement/ +│ │ ├── categories/ +│ │ │ ├── [categoryId]/ +│ │ │ └── history/ +│ │ ├── budgets/ +│ │ ├── payers/ +│ │ │ └── [payerId]/ +│ │ ├── notes/ +│ │ ├── insights/ +│ │ ├── calendar/ +│ │ ├── inbox/ +│ │ ├── changelog/ +│ │ ├── reports/ +│ │ │ ├── category-trends/ +│ │ │ ├── card-usage/ +│ │ │ ├── installment-analysis/ +│ │ │ └── establishments/ +│ │ └── settings/ +│ ├── (landing-page)/ +│ ├── api/ +│ ├── globals.css +│ └── layout.tsx +├── features/ +│ ├── auth/ +│ ├── landing/ +│ ├── dashboard/ +│ ├── transactions/ +│ ├── cards/ +│ ├── invoices/ +│ ├── accounts/ +│ ├── categories/ +│ ├── budgets/ +│ ├── payers/ +│ ├── notes/ +│ ├── insights/ +│ ├── calendar/ +│ ├── inbox/ +│ ├── reports/ +│ └── settings/ +├── shared/ +│ ├── components/ +│ │ ├── ui/ +│ │ ├── navigation/ +│ │ ├── providers/ +│ │ ├── month-picker/ +│ │ ├── logo-picker/ +│ │ ├── calculator/ +│ │ ├── entity-avatar/ +│ │ └── skeletons/ +│ ├── hooks/ +│ ├── lib/ +│ │ ├── actions/ +│ │ ├── auth/ +│ │ ├── accounts/ +│ │ ├── cards/ +│ │ ├── calculator/ +│ │ ├── categories/ +│ │ ├── email/ +│ │ ├── installments/ +│ │ ├── invoices/ +│ │ ├── logo/ +│ │ ├── payers/ +│ │ ├── schemas/ +│ │ ├── transfers/ +│ │ ├── types/ +│ │ └── db.ts +│ └── utils/ +│ ├── period/ +│ ├── currency.ts +│ ├── date.ts +│ ├── financial-dates.ts +│ ├── percentage.ts +│ ├── category-colors.ts +│ ├── calendar.ts +│ ├── math.ts +│ ├── number.ts +│ ├── string.ts +│ ├── initials.ts +│ ├── icons.tsx +│ ├── export-branding.ts +│ ├── ui.ts +│ └── calculator.ts +└── db/ + └── schema.ts +``` + +--- + +## Import Patterns + +### Preferidos + +```ts +import { getUser } from "@/shared/lib/auth/server"; +import { revalidateForEntity } from "@/shared/lib/actions/helpers"; +import { parsePeriodParam } from "@/shared/utils/period"; +import { TransactionsPage } from "@/features/transactions/components/page/transactions-page"; +import { fetchLancamentos } from "@/features/transactions/queries"; +``` + +### Evitar + +```ts +import { Something } from "@/components/..."; +import { Something } from "@/lib/..."; +import { something } from "@/app/(dashboard)/..."; +``` + +--- + +## App Router Pattern + +Paginas em `src/app/` devem ser finas: + +```ts +import { getUser } from "@/shared/lib/auth/server"; +import { TransactionsPage } from "@/features/transactions/components/page/transactions-page"; +import { fetchLancamentos } from "@/features/transactions/queries"; + +export default async function Page() { + const user = await getUser(); + const data = await fetchLancamentos([/* filters */]); + return ; +} +``` + +Layouts, `loading.tsx` e metadata continuam em `src/app/`. + +--- + +## Naming + +### Routes / folders + +| Portugues | English | +|---|---| +| `lancamentos` | `transactions` | +| `cartoes` | `cards` | +| `contas` | `accounts` | +| `categorias` | `categories` | +| `orcamentos` | `budgets` | +| `pagadores` | `payers` | +| `anotacoes` | `notes` | +| `calendario` | `calendar` | +| `ajustes` | `settings` | +| `pre-lancamentos` | `inbox` | +| `relatorios/tendencias` | `reports/category-trends` | +| `relatorios/uso-cartoes` | `reports/card-usage` | +| `relatorios/analise-parcelas` | `reports/installment-analysis` | +| `relatorios/estabelecimentos` | `reports/establishments` | +| `contas/[contaId]/extrato` | `accounts/[accountId]/statement` | +| `cartoes/[cartaoId]/fatura` | `cards/[cardId]/invoice` | +| `categorias/historico` | `categories/history` | +| `changelog` | `settings/changelog` | + +### Files + +- preferir `kebab-case` +- preferir nomes em ingles +- manter nomes internos de tipos/funcoes somente quando a troca aumentar risco sem ganho real + +--- + +## Commands + +```bash +pnpm run dev +pnpm run build +pnpm run lint +pnpm run lint:fix +pnpm exec next typegen +pnpm exec tsc --noEmit +pnpm run db:generate +pnpm run db:push +pnpm run db:studio +pnpm run docker:up:db +``` + +--- + +## Revalidation + +Arquivo: `src/shared/lib/actions/helpers.ts` + +- atualizar sempre os paths em ingles +- lembrar de manter a tag `"dashboard"` para invalidacoes financeiras + +--- + +## Auth + +- `getUser()` / `getUserId()` em `src/shared/lib/auth/server.ts` +- sessao deduplicada por request com `React.cache()` + +--- + +## Dashboard Fetcher + +Padrao recomendado: + +```ts +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; + +export async function fetchData(userId: string, period: string) { + const adminPayerId = await getAdminPayerId(userId); + if (!adminPayerId) return []; + + return db.query.transactions.findMany({ + where: /* sempre com userId + adminPayerId + period */, + }); +} +``` + +--- + +## New Feature Checklist + +1. Criar a rota fina em `src/app/(dashboard)//page.tsx` +2. Criar a feature em `src/features//` +3. Separar: + - `components/` + - `queries.ts` + - `actions.ts` + - `types.ts` ou `schemas.ts` quando fizer sentido +4. Extrair para `src/shared/` tudo que for reutilizavel +5. Atualizar navegacao e `revalidateForEntity()` se a feature tiver CRUD +6. Rodar: + - `pnpm exec next typegen` + - `pnpm exec tsc --noEmit` + - `pnpm run lint` + +--- + +## Response Style + +Quando o time pedir avaliacao de plano ou feature: + +1. Responder em portugues simples. +2. Listar 3-5 problemas principais. +3. Fechar com decisao pratica: + - aprova agora + - nao aprova agora + - o que ajustar antes de comecar codigo + +Exemplo: + +- "Nao aprovaria para comecar codigo imediatamente." +- "Primeiro ajustaria o doc com estes 5 pontos." diff --git a/README.md b/README.md index d30a0a8..5430a24 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@

- OpenMonetis Logo + OpenMonetis Logo

Projeto pessoal de gestão financeira. Self-hosted, manual e open source.

-> **📢 Este projeto foi renomeado de OpenSheets para OpenMonetis.** Se você conhecia o projeto pelo nome anterior, é o mesmo — só mudou o nome! - > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. +[![Version](https://img.shields.io/badge/version-2.0.0-blue?style=flat-square)](CHANGELOG.md) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) @@ -21,7 +20,7 @@ ---

- Dashboard Preview + Dashboard Preview

--- @@ -29,7 +28,8 @@ ## 📖 Índice - [Sobre o Projeto](#-sobre-o-projeto) -- [Início Rápido](#-início-rápido) +- [Instalação via Script](#-instalação-via-script) +- [Início Rápido (manual)](#-início-rápido) - [Scripts Disponíveis](#-scripts-disponíveis) - [Docker](#-docker) - [Variáveis de Ambiente](#-variáveis-de-ambiente) @@ -52,15 +52,15 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ **1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor. -**2. Não há Open Finance** — Você precisa registrar manualmente suas transações. +**2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente ou importar extratos nos formatos OFX e XLS/XLSX. **3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo. ### Funcionalidades -💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, extratos detalhados e importação em massa. +💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria. -📊 **Dashboard e relatórios** — 20+ widgets interativos, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel. +📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel. 💳 **Faturas de cartão** — Acompanhe faturas por período, controle limites e vencimentos. @@ -78,13 +78,13 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ 📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia como pré-lançamentos para revisão. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion). -⚙️ **Personalização** — Tema dark/light, modo privacidade, fontes customizáveis, preferências por usuário. +⚙️ **Personalização** — Tema dark/light e modo privacidade. ### Stack técnica - **Next.js** (App Router, Turbopack) + **React** + **TypeScript** - **PostgreSQL** + **Drizzle ORM** -- **Better Auth** (email/senha + OAuth) +- **Better Auth** (email/senha, OAuth, Passkeys/WebAuthn) - **shadcn/ui** (Radix UI) + **Tailwind CSS** - **Docker** (multi-stage build) - **Biome** (linting + formatting) @@ -92,7 +92,30 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ --- -## 🚀 Início Rápido +## ⚡ Instalação via Script + +A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente. + +**Pré-requisito:** Node.js 22+ + +```bash +# Mac / Linux / WSL +curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs + +# Windows (PowerShell) +curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs +``` + +O script irá: +- Verificar Node, pnpm, Git e Docker +- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.) +- Gerar o `BETTER_AUTH_SECRET` automaticamente +- Configurar opcionais: Google OAuth, e-mail, IA, domínio público +- Clonar o repositório, instalar dependências e aplicar o schema + +--- + +## 🚀 Início Rápido (manual) ### Pré-requisitos @@ -169,6 +192,12 @@ pnpm db:push # Push schema direto (dev) pnpm db:studio # Drizzle Studio (UI visual) ``` +### Utilitários + +```bash +pnpm backup # Backup do banco (requer scripts/backup.sh configurado) +``` + ### Docker ```bash @@ -251,32 +280,49 @@ OPENROUTER_API_KEY= ## 🏗️ Arquitetura +O projeto segue arquitetura **feature-first** dentro de `src/`: + ``` openmonetis/ -├── app/ # Next.js App Router -│ ├── api/ # API Routes (auth, health, inbox) -│ ├── (auth)/ # Login e cadastro -│ ├── (dashboard)/ # Rotas protegidas -│ └── (landing-page)/ # Página inicial pública +├── src/ +│ ├── app/ # Next.js App Router (rotas finas) +│ │ ├── api/ # API Routes (auth, health, inbox) +│ │ ├── (auth)/ # Login e cadastro +│ │ ├── (dashboard)/ # Rotas protegidas (transactions, cards, accounts, etc.) +│ │ └── (landing-page)/ # Página inicial pública +│ │ +│ ├── features/ # Código de domínio por feature +│ │ ├── dashboard/ # Widgets, queries e métricas +│ │ ├── transactions/ # Lançamentos, ações em lote, exportação +│ │ ├── cards/ # Cartões de crédito +│ │ ├── invoices/ # Faturas +│ │ ├── accounts/ # Contas bancárias +│ │ ├── categories/ # Categorias e histórico +│ │ ├── budgets/ # Orçamentos +│ │ ├── payers/ # Pagadores e compartilhamento +│ │ ├── inbox/ # Pré-lançamentos do Companion +│ │ ├── insights/ # Análises com IA +│ │ ├── reports/ # Relatórios e exportações +│ │ ├── notes/ # Anotações +│ │ ├── calendar/ # Calendário financeiro +│ │ ├── settings/ # Ajustes do usuário +│ │ ├── landing/ # Landing page +│ │ └── auth/ # Formulários de autenticação +│ │ +│ ├── shared/ # Código reutilizado entre features +│ │ ├── components/ # UI compartilhada (shadcn/ui, navigation, skeletons...) +│ │ ├── hooks/ # React hooks globais +│ │ ├── lib/ # Helpers de domínio (auth, db, payers, schemas, email...) +│ │ └── utils/ # Utilitários (currency, date, period, math, string...) +│ │ +│ └── db/ +│ └── schema.ts # Drizzle schema (fonte única de verdade) │ -├── components/ # React Components (~200 arquivos) -│ ├── ui/ # shadcn/ui (40+ componentes) -│ ├── dashboard/ # Widgets do dashboard (20+) -│ └── [feature]/ # Componentes por feature -│ -├── lib/ # Lógica de negócio -│ ├── auth/ # Auth helpers -│ ├── dashboard/ # Fetchers do dashboard -│ ├── actions/ # Server Actions helpers -│ ├── schemas/ # Zod schemas -│ └── utils/ # Currency, date, period utils -│ -├── db/schema.ts # Drizzle schema -├── hooks/ # React hooks customizados -├── public/ # Assets estáticos -├── scripts/ # Scripts utilitários -├── Dockerfile # Multi-stage build -├── docker-compose.yml # Orquestração +├── public/ # Assets estáticos (imagens, logos, fontes) +├── drizzle/ # Migrations geradas +├── scripts/ # Scripts utilitários (migrations, dev) +├── Dockerfile # Multi-stage build (~200MB, non-root) +├── docker-compose.yml # Orquestração app + PostgreSQL └── proxy.ts # Middleware (auth + multi-domínio) ``` @@ -291,7 +337,7 @@ openmonetis/ 5. **Push:** `git push origin feature/minha-feature` 6. Abra um **Pull Request** -Use TypeScript, commits semânticos e documente features novas. +Antes de começar, leia o [`CLAUDE.md`](CLAUDE.md) — ele documenta a arquitetura, convenções de nomenclatura, regras de queries e o checklist para novas features. Use TypeScript, commits semânticos e mantenha o `CHANGELOG.md` atualizado. --- diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx deleted file mode 100644 index a2235ca..0000000 --- a/app/(auth)/login/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { LoginForm } from "@/components/auth/login-form"; - -export default function LoginPage() { - return ( -
-
- -
-
- ); -} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx deleted file mode 100644 index dd81e6e..0000000 --- a/app/(auth)/signup/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { SignupForm } from "@/components/auth/signup-form"; - -export default function Page() { - return ( -
-
- -
-
- ); -} diff --git a/app/(dashboard)/ajustes/data.ts b/app/(dashboard)/ajustes/data.ts deleted file mode 100644 index 5c92561..0000000 --- a/app/(dashboard)/ajustes/data.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { desc, eq } from "drizzle-orm"; -import { tokensApi } from "@/db/schema"; -import { db, schema } from "@/lib/db"; -import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index"; - -export interface UserPreferences { - disableMagnetlines: boolean; - extratoNoteAsColumn: boolean; - lancamentosColumnOrder: string[] | null; - systemFont: FontKey; - moneyFont: FontKey; -} - -export interface ApiToken { - id: string; - name: string; - tokenPrefix: string; - lastUsedAt: Date | null; - lastUsedIp: string | null; - createdAt: Date; - expiresAt: Date | null; - revokedAt: Date | null; -} - -export async function fetchAuthProvider(userId: string): Promise { - const userAccount = await db.query.account.findFirst({ - where: eq(schema.account.userId, userId), - }); - return userAccount?.providerId || "credential"; -} - -export async function fetchUserPreferences( - userId: string, -): Promise { - const result = await db - .select({ - disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines, - extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn, - lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder, - systemFont: schema.preferenciasUsuario.systemFont, - moneyFont: schema.preferenciasUsuario.moneyFont, - }) - .from(schema.preferenciasUsuario) - .where(eq(schema.preferenciasUsuario.userId, userId)) - .limit(1); - - if (!result[0]) return null; - - return { - ...result[0], - systemFont: normalizeFontKey(result[0].systemFont), - moneyFont: normalizeFontKey(result[0].moneyFont), - }; -} - -export async function fetchApiTokens(userId: string): Promise { - return db - .select({ - id: tokensApi.id, - name: tokensApi.name, - tokenPrefix: tokensApi.tokenPrefix, - lastUsedAt: tokensApi.lastUsedAt, - lastUsedIp: tokensApi.lastUsedIp, - createdAt: tokensApi.createdAt, - expiresAt: tokensApi.expiresAt, - revokedAt: tokensApi.revokedAt, - }) - .from(tokensApi) - .where(eq(tokensApi.userId, userId)) - .orderBy(desc(tokensApi.createdAt)); -} - -export async function fetchAjustesPageData(userId: string) { - const [authProvider, userPreferences, userApiTokens] = await Promise.all([ - fetchAuthProvider(userId), - fetchUserPreferences(userId), - fetchApiTokens(userId), - ]); - - return { - authProvider, - userPreferences, - userApiTokens, - }; -} diff --git a/app/(dashboard)/anotacoes/data.ts b/app/(dashboard)/anotacoes/data.ts deleted file mode 100644 index 4697516..0000000 --- a/app/(dashboard)/anotacoes/data.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { type Anotacao, anotacoes } from "@/db/schema"; -import { db } from "@/lib/db"; - -export type Task = { - id: string; - text: string; - completed: boolean; -}; - -export type NoteData = { - id: string; - title: string; - description: string; - type: "nota" | "tarefa"; - tasks?: Task[]; - arquivada: boolean; - createdAt: string; -}; - -export async function fetchNotesForUser(userId: string): Promise { - const noteRows = await db.query.anotacoes.findMany({ - where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)), - orderBy: ( - note: typeof anotacoes.$inferSelect, - { desc }: { desc: (field: unknown) => unknown }, - ) => [desc(note.createdAt)], - }); - - return noteRows.map((note: Anotacao) => { - let tasks: Task[] | undefined; - - // Parse tasks if they exist - if (note.tasks) { - try { - tasks = JSON.parse(note.tasks); - } catch (error) { - console.error("Failed to parse tasks for note", note.id, error); - tasks = undefined; - } - } - - return { - id: note.id, - title: (note.title ?? "").trim(), - description: (note.description ?? "").trim(), - type: (note.type ?? "nota") as "nota" | "tarefa", - tasks, - arquivada: note.arquivada, - createdAt: note.createdAt.toISOString(), - }; - }); -} - -export async function fetchAllNotesForUser( - userId: string, -): Promise<{ activeNotes: NoteData[]; archivedNotes: NoteData[] }> { - const [activeNotes, archivedNotes] = await Promise.all([ - fetchNotesForUser(userId), - fetchArquivadasForUser(userId), - ]); - - return { activeNotes, archivedNotes }; -} - -export async function fetchArquivadasForUser( - userId: string, -): Promise { - const noteRows = await db.query.anotacoes.findMany({ - where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)), - orderBy: ( - note: typeof anotacoes.$inferSelect, - { desc }: { desc: (field: unknown) => unknown }, - ) => [desc(note.createdAt)], - }); - - return noteRows.map((note: Anotacao) => { - let tasks: Task[] | undefined; - - // Parse tasks if they exist - if (note.tasks) { - try { - tasks = JSON.parse(note.tasks); - } catch (error) { - console.error("Failed to parse tasks for note", note.id, error); - tasks = undefined; - } - } - - return { - id: note.id, - title: (note.title ?? "").trim(), - description: (note.description ?? "").trim(), - type: (note.type ?? "nota") as "nota" | "tarefa", - tasks, - arquivada: note.arquivada, - createdAt: note.createdAt.toISOString(), - }; - }); -} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts deleted file mode 100644 index 9a1e870..0000000 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts +++ /dev/null @@ -1,292 +0,0 @@ -"use server"; - -import { and, eq, sql } from "drizzle-orm"; -import { z } from "zod"; -import { - cartoes, - categorias, - faturas, - lancamentos, - pagadores, -} from "@/db/schema"; -import { revalidateForEntity } from "@/lib/actions/helpers"; -import { getUser } from "@/lib/auth/server"; -import { buildInvoicePaymentNote } from "@/lib/contas/constants"; -import { db } from "@/lib/db"; -import { - INVOICE_PAYMENT_STATUS, - INVOICE_STATUS_VALUES, - type InvoicePaymentStatus, - PERIOD_FORMAT_REGEX, -} from "@/lib/faturas"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { parseLocalDateString } from "@/lib/utils/date"; - -const updateInvoicePaymentStatusSchema = z.object({ - cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), - period: z - .string({ message: "Período inválido." }) - .regex(PERIOD_FORMAT_REGEX, "Período inválido."), - status: z.enum( - INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]], - ), - paymentDate: z.string().optional(), -}); - -type UpdateInvoicePaymentStatusInput = z.infer< - typeof updateInvoicePaymentStatusSchema ->; - -type ActionResult = - | { success: true; message: string } - | { success: false; error: string }; - -const successMessageByStatus: Record = { - [INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.", - [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", -}; - -const formatDecimal = (value: number) => - (Math.round(value * 100) / 100).toFixed(2); - -export async function updateInvoicePaymentStatusAction( - input: UpdateInvoicePaymentStatusInput, -): Promise { - try { - const user = await getUser(); - const data = updateInvoicePaymentStatusSchema.parse(input); - - await db.transaction(async (tx: typeof db) => { - const card = await tx.query.cartoes.findFirst({ - columns: { id: true, contaId: true, name: true }, - where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), - }); - - if (!card) { - throw new Error("Cartão não encontrado."); - } - - const existingInvoice = await tx.query.faturas.findFirst({ - columns: { - id: true, - }, - where: and( - eq(faturas.cartaoId, data.cartaoId), - eq(faturas.userId, user.id), - eq(faturas.period, data.period), - ), - }); - - if (existingInvoice) { - await tx - .update(faturas) - .set({ - paymentStatus: data.status, - }) - .where(eq(faturas.id, existingInvoice.id)); - } else { - await tx.insert(faturas).values({ - cartaoId: data.cartaoId, - period: data.period, - paymentStatus: data.status, - userId: user.id, - }); - } - - const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID; - - await tx - .update(lancamentos) - .set({ isSettled: shouldMarkAsPaid }) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.cartaoId, card.id), - eq(lancamentos.period, data.period), - ), - ); - - const invoiceNote = buildInvoicePaymentNote(card.id, data.period); - - if (shouldMarkAsPaid) { - const [adminShareRow] = await tx - .select({ - total: sql` - coalesce( - sum( - case - when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount} - else 0 - end - ), - 0 - ) - `, - }) - .from(lancamentos) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.cartaoId, card.id), - eq(lancamentos.period, data.period), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ), - ); - - const adminShare = Math.abs(Number(adminShareRow?.total ?? 0)); - - if (adminShare > 0 && card.contaId) { - const adminPagador = await tx.query.pagadores.findFirst({ - columns: { id: true }, - where: and( - eq(pagadores.userId, user.id), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ), - }); - - const paymentCategory = await tx.query.categorias.findFirst({ - columns: { id: true }, - where: and( - eq(categorias.userId, user.id), - eq(categorias.name, "Pagamentos"), - ), - }); - - if (adminPagador) { - // Usar a data customizada ou a data atual como data de pagamento - const invoiceDate = data.paymentDate - ? parseLocalDateString(data.paymentDate) - : new Date(); - - const amount = `-${formatDecimal(adminShare)}`; - const payload = { - condition: "À vista", - name: `Pagamento fatura - ${card.name}`, - paymentMethod: "Pix", - note: invoiceNote, - amount, - purchaseDate: invoiceDate, - transactionType: "Despesa" as const, - period: data.period, - isSettled: true, - userId: user.id, - contaId: card.contaId, - categoriaId: paymentCategory?.id ?? null, - pagadorId: adminPagador.id, - }; - - const existingPayment = await tx.query.lancamentos.findFirst({ - columns: { id: true }, - where: and( - eq(lancamentos.userId, user.id), - eq(lancamentos.note, invoiceNote), - ), - }); - - if (existingPayment) { - await tx - .update(lancamentos) - .set(payload) - .where(eq(lancamentos.id, existingPayment.id)); - } else { - await tx.insert(lancamentos).values(payload); - } - } - } - } else { - await tx - .delete(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.note, invoiceNote), - ), - ); - } - }); - - revalidateForEntity("cartoes"); - - return { success: true, message: successMessageByStatus[data.status] }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message ?? "Dados inválidos.", - }; - } - - return { - success: false, - error: error instanceof Error ? error.message : "Erro inesperado.", - }; - } -} - -const updatePaymentDateSchema = z.object({ - cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), - period: z - .string({ message: "Período inválido." }) - .regex(PERIOD_FORMAT_REGEX, "Período inválido."), - paymentDate: z.string({ message: "Data de pagamento inválida." }), -}); - -type UpdatePaymentDateInput = z.infer; - -export async function updatePaymentDateAction( - input: UpdatePaymentDateInput, -): Promise { - try { - const user = await getUser(); - const data = updatePaymentDateSchema.parse(input); - - await db.transaction(async (tx: typeof db) => { - const card = await tx.query.cartoes.findFirst({ - columns: { id: true }, - where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), - }); - - if (!card) { - throw new Error("Cartão não encontrado."); - } - - const invoiceNote = buildInvoicePaymentNote(card.id, data.period); - - const existingPayment = await tx.query.lancamentos.findFirst({ - columns: { id: true }, - where: and( - eq(lancamentos.userId, user.id), - eq(lancamentos.note, invoiceNote), - ), - }); - - if (!existingPayment) { - throw new Error("Pagamento não encontrado."); - } - - await tx - .update(lancamentos) - .set({ - purchaseDate: parseLocalDateString(data.paymentDate), - }) - .where(eq(lancamentos.id, existingPayment.id)); - }); - - revalidateForEntity("cartoes"); - - return { success: true, message: "Data de pagamento atualizada." }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message ?? "Dados inválidos.", - }; - } - - return { - success: false, - error: error instanceof Error ? error.message : "Erro inesperado.", - }; - } -} diff --git a/app/(dashboard)/cartoes/data.ts b/app/(dashboard)/cartoes/data.ts deleted file mode 100644 index 3ede582..0000000 --- a/app/(dashboard)/cartoes/data.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm"; -import { cartoes, contas, lancamentos } from "@/db/schema"; -import { db } from "@/lib/db"; -import { loadLogoOptions } from "@/lib/logo/options"; - -export type CardData = { - id: string; - name: string; - brand: string | null; - status: string | null; - closingDay: number; - dueDay: number; - note: string | null; - logo: string | null; - limit: number | null; - limitInUse: number; - limitAvailable: number | null; - contaId: string; - contaName: string; -}; - -export type AccountSimple = { - id: string; - name: string; - logo: string | null; -}; - -export async function fetchCardsForUser(userId: string): Promise<{ - cards: CardData[]; - accounts: AccountSimple[]; - logoOptions: LogoOption[]; -}> { - const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ - db.query.cartoes.findMany({ - orderBy: ( - card: typeof cartoes.$inferSelect, - { desc }: { desc: (field: unknown) => unknown }, - ) => [desc(card.name)], - where: and( - eq(cartoes.userId, userId), - not(ilike(cartoes.status, "inativo")), - ), - with: { - conta: { - columns: { - id: true, - name: true, - }, - }, - }, - }), - db.query.contas.findMany({ - orderBy: ( - account: typeof contas.$inferSelect, - { desc }: { desc: (field: unknown) => unknown }, - ) => [desc(account.name)], - where: eq(contas.userId, userId), - columns: { - id: true, - name: true, - logo: true, - }, - }), - loadLogoOptions(), - db - .select({ - cartaoId: lancamentos.cartaoId, - total: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)), - // Recorrente no cartão: só consome limite quando a data da ocorrência já passou - or( - ne(lancamentos.condition, "Recorrente"), - sql`${lancamentos.purchaseDate} <= current_date`, - ), - ), - ) - .groupBy(lancamentos.cartaoId), - ]); - - const usageMap = new Map(); - usageRows.forEach( - (row: { cartaoId: string | null; total: number | null }) => { - if (!row.cartaoId) return; - usageMap.set(row.cartaoId, Number(row.total ?? 0)); - }, - ); - - const cards = cardRows.map((card) => ({ - id: card.id, - name: card.name, - brand: card.brand, - status: card.status, - closingDay: card.closingDay, - dueDay: card.dueDay, - note: card.note, - logo: card.logo, - limit: card.limit ? Number(card.limit) : null, - limitInUse: (() => { - const total = usageMap.get(card.id) ?? 0; - return total < 0 ? Math.abs(total) : 0; - })(), - limitAvailable: (() => { - if (!card.limit) { - return null; - } - const total = usageMap.get(card.id) ?? 0; - const inUse = total < 0 ? Math.abs(total) : 0; - return Math.max(Number(card.limit) - inUse, 0); - })(), - contaId: card.contaId, - contaName: card.conta?.name ?? "Conta não encontrada", - })); - - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - logo: account.logo, - })); - - return { cards, accounts, logoOptions }; -} - -export async function fetchInativosForUser(userId: string): Promise<{ - cards: CardData[]; - accounts: AccountSimple[]; - logoOptions: LogoOption[]; -}> { - const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ - db.query.cartoes.findMany({ - orderBy: ( - card: typeof cartoes.$inferSelect, - { desc }: { desc: (field: unknown) => unknown }, - ) => [desc(card.name)], - where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")), - with: { - conta: { - columns: { - id: true, - name: true, - }, - }, - }, - }), - db.query.contas.findMany({ - orderBy: ( - account: typeof contas.$inferSelect, - { desc }: { desc: (field: unknown) => unknown }, - ) => [desc(account.name)], - where: eq(contas.userId, userId), - columns: { - id: true, - name: true, - logo: true, - }, - }), - loadLogoOptions(), - db - .select({ - cartaoId: lancamentos.cartaoId, - total: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)), - // Recorrente no cartão: só consome limite quando a data da ocorrência já passou - or( - ne(lancamentos.condition, "Recorrente"), - sql`${lancamentos.purchaseDate} <= current_date`, - ), - ), - ) - .groupBy(lancamentos.cartaoId), - ]); - - const usageMap = new Map(); - usageRows.forEach( - (row: { cartaoId: string | null; total: number | null }) => { - if (!row.cartaoId) return; - usageMap.set(row.cartaoId, Number(row.total ?? 0)); - }, - ); - - const cards = cardRows.map((card) => ({ - id: card.id, - name: card.name, - brand: card.brand, - status: card.status, - closingDay: card.closingDay, - dueDay: card.dueDay, - note: card.note, - logo: card.logo, - limit: card.limit ? Number(card.limit) : null, - limitInUse: (() => { - const total = usageMap.get(card.id) ?? 0; - return total < 0 ? Math.abs(total) : 0; - })(), - limitAvailable: (() => { - if (!card.limit) { - return null; - } - const total = usageMap.get(card.id) ?? 0; - const inUse = total < 0 ? Math.abs(total) : 0; - return Math.max(Number(card.limit) - inUse, 0); - })(), - contaId: card.contaId, - contaName: card.conta?.name ?? "Conta não encontrada", - })); - - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - logo: account.logo, - })); - - return { cards, accounts, logoOptions }; -} - -export async function fetchAllCardsForUser(userId: string): Promise<{ - activeCards: CardData[]; - archivedCards: CardData[]; - accounts: AccountSimple[]; - logoOptions: LogoOption[]; -}> { - const [activeData, archivedData] = await Promise.all([ - fetchCardsForUser(userId), - fetchInativosForUser(userId), - ]); - - return { - activeCards: activeData.cards, - archivedCards: archivedData.cards, - accounts: activeData.accounts, - logoOptions: activeData.logoOptions, - }; -} diff --git a/app/(dashboard)/cartoes/loading.tsx b/app/(dashboard)/cartoes/loading.tsx deleted file mode 100644 index 4908802..0000000 --- a/app/(dashboard)/cartoes/loading.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function CartoesLoading() { - return ( -
-
- {/* Header */} -
- - -
- - {/* Grid de cartões */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
- - -
- - - -
- ))} -
-
-
- ); -} diff --git a/app/(dashboard)/categorias/historico/page.tsx b/app/(dashboard)/categorias/historico/page.tsx deleted file mode 100644 index 4541dee..0000000 --- a/app/(dashboard)/categorias/historico/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { CategoryHistoryWidget } from "@/components/dashboard/category-history-widget"; -import { getUser } from "@/lib/auth/server"; -import { fetchCategoryHistory } from "@/lib/dashboard/categories/category-history"; -import { getCurrentPeriod } from "@/lib/utils/period"; - -export default async function HistoricoCategoriasPage() { - const user = await getUser(); - const currentPeriod = getCurrentPeriod(); - - const data = await fetchCategoryHistory(user.id, currentPeriod); - - return ( -
- -
- ); -} diff --git a/app/(dashboard)/contas/[contaId]/extrato/data.ts b/app/(dashboard)/contas/[contaId]/extrato/data.ts deleted file mode 100644 index 06f701a..0000000 --- a/app/(dashboard)/contas/[contaId]/extrato/data.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm"; -import { contas, lancamentos, pagadores } from "@/db/schema"; -import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants"; -import { db } from "@/lib/db"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; - -export type AccountSummaryData = { - openingBalance: number; - currentBalance: number; - totalIncomes: number; - totalExpenses: number; -}; - -export async function fetchAccountData(userId: string, contaId: string) { - const account = await db.query.contas.findFirst({ - columns: { - id: true, - name: true, - accountType: true, - status: true, - initialBalance: true, - logo: true, - note: true, - }, - where: and(eq(contas.id, contaId), eq(contas.userId, userId)), - }); - - return account; -} - -export async function fetchAccountSummary( - userId: string, - contaId: string, - selectedPeriod: string, -): Promise { - const [periodSummary] = await db - .select({ - netAmount: sql` - coalesce( - sum( - case - when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 - else ${lancamentos.amount} - end - ), - 0 - ) - `, - incomes: sql` - coalesce( - sum( - case - when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 - when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount} - else 0 - end - ), - 0 - ) - `, - expenses: sql` - coalesce( - sum( - case - when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 - when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount} - else 0 - end - ), - 0 - ) - `, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.contaId, contaId), - eq(lancamentos.period, selectedPeriod), - eq(lancamentos.isSettled, true), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ), - ); - - const [previousRow] = await db - .select({ - previousMovements: sql` - coalesce( - sum( - case - when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 - else ${lancamentos.amount} - end - ), - 0 - ) - `, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.contaId, contaId), - lt(lancamentos.period, selectedPeriod), - eq(lancamentos.isSettled, true), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ), - ); - - const account = await fetchAccountData(userId, contaId); - if (!account) { - throw new Error("Account not found"); - } - - const initialBalance = Number(account.initialBalance ?? 0); - const previousMovements = Number(previousRow?.previousMovements ?? 0); - const openingBalance = initialBalance + previousMovements; - const netAmount = Number(periodSummary?.netAmount ?? 0); - const totalIncomes = Number(periodSummary?.incomes ?? 0); - const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); - const currentBalance = openingBalance + netAmount; - - return { - openingBalance, - currentBalance, - totalIncomes, - totalExpenses, - }; -} - -export async function fetchAccountLancamentos( - filters: SQL[], - settledOnly = true, -) { - const allFilters = settledOnly - ? [...filters, eq(lancamentos.isSettled, true)] - : filters; - - return db.query.lancamentos.findMany({ - where: and(...allFilters), - with: { - pagador: true, - conta: true, - cartao: true, - categoria: true, - }, - orderBy: desc(lancamentos.purchaseDate), - }); -} diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx deleted file mode 100644 index bb928dd..0000000 --- a/app/(dashboard)/contas/[contaId]/extrato/page.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { RiPencilLine } from "@remixicon/react"; -import { notFound } from "next/navigation"; -import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; -import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; -import { AccountDialog } from "@/components/contas/account-dialog"; -import { AccountStatementCard } from "@/components/contas/account-statement-card"; -import type { Account } from "@/components/contas/types"; -import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; -import MonthNavigation from "@/components/month-picker/month-navigation"; -import { Button } from "@/components/ui/button"; -import { getUserId } from "@/lib/auth/server"; -import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - mapLancamentosData, - type ResolvedSearchParams, -} from "@/lib/lancamentos/page-helpers"; -import { loadLogoOptions } from "@/lib/logo/options"; -import { parsePeriodParam } from "@/lib/utils/period"; -import { - fetchAccountData, - fetchAccountLancamentos, - fetchAccountSummary, -} from "./data"; - -type PageSearchParams = Promise; - -type PageProps = { - params: Promise<{ contaId: string }>; - searchParams?: PageSearchParams; -}; - -const capitalize = (value: string) => - value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; - -export default async function Page({ params, searchParams }: PageProps) { - const { contaId } = await params; - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { - period: selectedPeriod, - monthName, - year, - } = parsePeriodParam(periodoParamRaw); - - const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - - const account = await fetchAccountData(userId, contaId); - - if (!account) { - notFound(); - } - - const [ - filterSources, - logoOptions, - accountSummary, - estabelecimentos, - userPreferences, - ] = await Promise.all([ - fetchLancamentoFilterSources(userId), - loadLogoOptions(), - fetchAccountSummary(userId, contaId, selectedPeriod), - getRecentEstablishmentsAction(), - fetchUserPreferences(userId), - ]); - const sluggedFilters = buildSluggedFilters(filterSources); - const slugMaps = buildSlugMaps(sluggedFilters); - - const filters = buildLancamentoWhere({ - userId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - accountId: account.id, - }); - - const lancamentoRows = await fetchAccountLancamentos(filters); - - const lancamentosData = mapLancamentosData(lancamentoRows); - - const { openingBalance, currentBalance, totalIncomes, totalExpenses } = - accountSummary; - - const periodLabel = `${capitalize(monthName)} de ${year}`; - - const accountDialogData: Account = { - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: currentBalance, - }; - - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - limitContaId: account.id, - }); - - return ( -
- - - - - - } - /> - } - /> - -
- -
-
- ); -} diff --git a/app/(dashboard)/contas/data.ts b/app/(dashboard)/contas/data.ts deleted file mode 100644 index 3cc927e..0000000 --- a/app/(dashboard)/contas/data.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { and, eq, ilike, not, sql } from "drizzle-orm"; -import { contas, lancamentos, pagadores } from "@/db/schema"; -import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants"; -import { db } from "@/lib/db"; -import { loadLogoOptions } from "@/lib/logo/options"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; - -export type AccountData = { - id: string; - name: string; - accountType: string; - status: string; - note: string | null; - logo: string | null; - initialBalance: number; - balance: number; - excludeFromBalance: boolean; - excludeInitialBalanceFromIncome: boolean; -}; - -export async function fetchAccountsForUser( - userId: string, -): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> { - const [accountRows, logoOptions] = await Promise.all([ - db - .select({ - id: contas.id, - name: contas.name, - accountType: contas.accountType, - status: contas.status, - note: contas.note, - logo: contas.logo, - initialBalance: contas.initialBalance, - excludeFromBalance: contas.excludeFromBalance, - excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, - balanceMovements: sql` - coalesce( - sum( - case - when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 - else ${lancamentos.amount} - end - ), - 0 - ) - `, - }) - .from(contas) - .leftJoin( - lancamentos, - and( - eq(lancamentos.contaId, contas.id), - eq(lancamentos.userId, userId), - eq(lancamentos.isSettled, true), - ), - ) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(contas.userId, userId), - not(ilike(contas.status, "inativa")), - sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`, - ), - ) - .groupBy( - contas.id, - contas.name, - contas.accountType, - contas.status, - contas.note, - contas.logo, - contas.initialBalance, - contas.excludeFromBalance, - contas.excludeInitialBalanceFromIncome, - ), - loadLogoOptions(), - ]); - - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: - Number(account.initialBalance ?? 0) + - Number(account.balanceMovements ?? 0), - excludeFromBalance: account.excludeFromBalance, - excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, - })); - - return { accounts, logoOptions }; -} - -export async function fetchInativosForUser( - userId: string, -): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> { - const [accountRows, logoOptions] = await Promise.all([ - db - .select({ - id: contas.id, - name: contas.name, - accountType: contas.accountType, - status: contas.status, - note: contas.note, - logo: contas.logo, - initialBalance: contas.initialBalance, - excludeFromBalance: contas.excludeFromBalance, - excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, - balanceMovements: sql` - coalesce( - sum( - case - when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 - else ${lancamentos.amount} - end - ), - 0 - ) - `, - }) - .from(contas) - .leftJoin( - lancamentos, - and( - eq(lancamentos.contaId, contas.id), - eq(lancamentos.userId, userId), - eq(lancamentos.isSettled, true), - ), - ) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(contas.userId, userId), - ilike(contas.status, "inativa"), - sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`, - ), - ) - .groupBy( - contas.id, - contas.name, - contas.accountType, - contas.status, - contas.note, - contas.logo, - contas.initialBalance, - contas.excludeFromBalance, - contas.excludeInitialBalanceFromIncome, - ), - loadLogoOptions(), - ]); - - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: - Number(account.initialBalance ?? 0) + - Number(account.balanceMovements ?? 0), - excludeFromBalance: account.excludeFromBalance, - excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, - })); - - return { accounts, logoOptions }; -} - -export async function fetchAllAccountsForUser(userId: string): Promise<{ - activeAccounts: AccountData[]; - archivedAccounts: AccountData[]; - logoOptions: LogoOption[]; -}> { - const [activeData, archivedData] = await Promise.all([ - fetchAccountsForUser(userId), - fetchInativosForUser(userId), - ]); - - return { - activeAccounts: activeData.accounts, - archivedAccounts: archivedData.accounts, - logoOptions: activeData.logoOptions, - }; -} diff --git a/app/(dashboard)/contas/loading.tsx b/app/(dashboard)/contas/loading.tsx deleted file mode 100644 index 334b496..0000000 --- a/app/(dashboard)/contas/loading.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function ContasLoading() { - return ( -
-
- {/* Header */} -
- - -
- - {/* Grid de contas */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
- - -
- - -
- - -
-
- ))} -
-
-
- ); -} diff --git a/app/(dashboard)/dashboard/data.ts b/app/(dashboard)/dashboard/data.ts deleted file mode 100644 index 2910641..0000000 --- a/app/(dashboard)/dashboard/data.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { eq } from "drizzle-orm"; -import { db, schema } from "@/lib/db"; - -export interface UserDashboardPreferences { - disableMagnetlines: boolean; - dashboardWidgets: string | null; -} - -export async function fetchUserDashboardPreferences( - userId: string, -): Promise { - const result = await db - .select({ - disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines, - dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets, - }) - .from(schema.preferenciasUsuario) - .where(eq(schema.preferenciasUsuario.userId, userId)) - .limit(1); - - return { - disableMagnetlines: result[0]?.disableMagnetlines ?? false, - dashboardWidgets: result[0]?.dashboardWidgets ?? null, - }; -} diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx deleted file mode 100644 index 6d4ed30..0000000 --- a/app/(dashboard)/dashboard/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { DashboardGridSkeleton } from "@/components/shared/skeletons"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function DashboardLoading() { - return ( -
- {/* Welcome Banner skeleton */} - - - {/* Month Picker skeleton */} - - - {/* Dashboard content skeleton (Section Cards + Widget Grid) */} - -
- ); -} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx deleted file mode 100644 index fd5da45..0000000 --- a/app/(dashboard)/dashboard/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { DashboardGridEditable } from "@/components/dashboard/dashboard-grid-editable"; -import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome"; -import { SectionCards } from "@/components/dashboard/section-cards"; -import MonthNavigation from "@/components/month-picker/month-navigation"; -import { getUser } from "@/lib/auth/server"; -import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; -import { - buildOptionSets, - buildSluggedFilters, - fetchLancamentoFilterSources, -} from "@/lib/lancamentos/page-helpers"; -import { parsePeriodParam } from "@/lib/utils/period"; -import { getRecentEstablishmentsAction } from "../lancamentos/actions"; -import { fetchUserDashboardPreferences } from "./data"; - -type PageSearchParams = Promise>; - -type PageProps = { - searchParams?: PageSearchParams; -}; - -const getSingleParam = ( - params: Record | undefined, - key: string, -) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? (value[0] ?? null) : value; -}; - -export default async function Page({ searchParams }: PageProps) { - const user = await getUser(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const { period: selectedPeriod } = parsePeriodParam(periodoParam); - - const [dashboardData, preferences, filterSources, estabelecimentos] = - await Promise.all([ - fetchDashboardData(user.id, selectedPeriod), - fetchUserDashboardPreferences(user.id), - fetchLancamentoFilterSources(user.id), - getRecentEstablishmentsAction(), - ]); - const { disableMagnetlines, dashboardWidgets } = preferences; - const sluggedFilters = buildSluggedFilters(filterSources); - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); - - return ( -
- - - - -
- ); -} diff --git a/app/(dashboard)/insights/actions.ts b/app/(dashboard)/insights/actions.ts deleted file mode 100644 index 73d138d..0000000 --- a/app/(dashboard)/insights/actions.ts +++ /dev/null @@ -1,871 +0,0 @@ -"use server"; - -import { anthropic } from "@ai-sdk/anthropic"; -import { google } from "@ai-sdk/google"; -import { openai } from "@ai-sdk/openai"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { generateObject } from "ai"; -import { getDay } from "date-fns"; -import { and, eq, isNull, ne, or, sql } from "drizzle-orm"; -import { - cartoes, - categorias, - contas, - insightsSalvos, - lancamentos, - orcamentos, - pagadores, -} from "@/db/schema"; -import { getUser } from "@/lib/auth/server"; -import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { db } from "@/lib/db"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { - type InsightsResponse, - InsightsResponseSchema, -} from "@/lib/schemas/insights"; -import { getPreviousPeriod } from "@/lib/utils/period"; -import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data"; - -const TRANSFERENCIA = "Transferência"; - -type ActionResult = - | { success: true; data: T } - | { success: false; error: string }; - -/** - * Função auxiliar para converter valores numéricos - */ -const toNumber = (value: unknown): number => { - if (typeof value === "number") return value; - if (typeof value === "string") { - const parsed = Number(value); - return Number.isNaN(parsed) ? 0 : parsed; - } - return 0; -}; - -/** - * Agrega dados financeiros do mês para análise - */ -async function aggregateMonthData(userId: string, period: string) { - const previousPeriod = getPreviousPeriod(period); - const twoMonthsAgo = getPreviousPeriod(previousPeriod); - const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo); - - // Buscar métricas de receitas e despesas dos últimos 3 meses - const [ - currentPeriodRows, - previousPeriodRows, - twoMonthsAgoRows, - threeMonthsAgoRows, - ] = await Promise.all([ - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), - ) - .groupBy(lancamentos.transactionType), - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, previousPeriod), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), - ) - .groupBy(lancamentos.transactionType), - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, twoMonthsAgo), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), - ) - .groupBy(lancamentos.transactionType), - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, threeMonthsAgo), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), - ) - .groupBy(lancamentos.transactionType), - ]); - - // Calcular totais dos últimos 3 meses - let currentIncome = 0; - let currentExpense = 0; - let previousIncome = 0; - let previousExpense = 0; - let twoMonthsAgoIncome = 0; - let twoMonthsAgoExpense = 0; - let threeMonthsAgoIncome = 0; - let threeMonthsAgoExpense = 0; - - for (const row of currentPeriodRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") currentIncome += amount; - else if (row.transactionType === "Despesa") currentExpense += amount; - } - - for (const row of previousPeriodRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") previousIncome += amount; - else if (row.transactionType === "Despesa") previousExpense += amount; - } - - for (const row of twoMonthsAgoRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") twoMonthsAgoIncome += amount; - else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount; - } - - for (const row of threeMonthsAgoRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") threeMonthsAgoIncome += amount; - else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount; - } - - // Buscar despesas por categoria (top 5) - const expensesByCategory = await db - .select({ - categoryName: categorias.name, - total: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - eq(categorias.type, "despesa"), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), - ) - .groupBy(categorias.name) - .orderBy(sql`sum(${lancamentos.amount}) ASC`) - .limit(5); - - // Buscar orçamentos e uso - const budgetsData = await db - .select({ - categoryName: categorias.name, - budgetAmount: orcamentos.amount, - spent: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(orcamentos) - .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) - .leftJoin( - lancamentos, - and( - eq(lancamentos.categoriaId, categorias.id), - eq(lancamentos.period, period), - eq(lancamentos.userId, userId), - eq(lancamentos.transactionType, "Despesa"), - ), - ) - .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) - .groupBy(categorias.name, orcamentos.amount); - - // Buscar métricas de cartões - const cardsData = await db - .select({ - totalLimit: sql`coalesce(sum(${cartoes.limit}), 0)`, - cardCount: sql`count(*)`, - }) - .from(cartoes) - .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))); - - // Buscar saldo total das contas - const accountsData = await db - .select({ - totalBalance: sql`coalesce(sum(${contas.initialBalance}), 0)`, - accountCount: sql`count(*)`, - }) - .from(contas) - .where( - and( - eq(contas.userId, userId), - eq(contas.status, "ativa"), - eq(contas.excludeFromBalance, false), - ), - ); - - // Calcular ticket médio das transações - const avgTicketData = await db - .select({ - avgAmount: sql`coalesce(avg(abs(${lancamentos.amount})), 0)`, - transactionCount: sql`count(*)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - ), - ); - - // Buscar gastos por dia da semana - const dayOfWeekSpending = await db - .select({ - purchaseDate: lancamentos.purchaseDate, - amount: lancamentos.amount, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ), - ); - - // Agregar por dia da semana - const dayTotals = new Map(); - for (const row of dayOfWeekSpending) { - if (!row.purchaseDate) continue; - const dayOfWeek = getDay(new Date(row.purchaseDate)); - const current = dayTotals.get(dayOfWeek) ?? 0; - dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount))); - } - - // Buscar métodos de pagamento (agregado) - const paymentMethodsData = await db - .select({ - paymentMethod: lancamentos.paymentMethod, - total: sql`coalesce(sum(abs(${lancamentos.amount})), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ), - ) - .groupBy(lancamentos.paymentMethod); - - // Buscar transações dos últimos 3 meses para análise de recorrência - const last3MonthsTransactions = await db - .select({ - name: lancamentos.name, - amount: lancamentos.amount, - period: lancamentos.period, - condition: lancamentos.condition, - installmentCount: lancamentos.installmentCount, - currentInstallment: lancamentos.currentInstallment, - categoryName: categorias.name, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where( - and( - eq(lancamentos.userId, userId), - sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`, - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - ), - ) - .orderBy(lancamentos.name); - - // Análise de recorrência - const transactionsByName = new Map< - string, - Array<{ period: string; amount: number }> - >(); - - for (const tx of last3MonthsTransactions) { - const key = tx.name.toLowerCase().trim(); - if (!transactionsByName.has(key)) { - transactionsByName.set(key, []); - } - const transactions = transactionsByName.get(key); - if (transactions) { - transactions.push({ - period: tx.period, - amount: Math.abs(toNumber(tx.amount)), - }); - } - } - - // Identificar gastos recorrentes (aparece em 2+ meses com valor similar) - const recurringExpenses: Array<{ - name: string; - avgAmount: number; - frequency: number; - }> = []; - let totalRecurring = 0; - - for (const [name, occurrences] of transactionsByName.entries()) { - if (occurrences.length >= 2) { - const amounts = occurrences.map((o) => o.amount); - const avgAmount = - amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length; - const maxDiff = Math.max(...amounts) - Math.min(...amounts); - - // Considerar recorrente se variação <= 20% da média - if (maxDiff <= avgAmount * 0.2) { - recurringExpenses.push({ - name, - avgAmount, - frequency: occurrences.length, - }); - - // Somar apenas os do mês atual - const currentMonthOccurrence = occurrences.find( - (o) => o.period === period, - ); - if (currentMonthOccurrence) { - totalRecurring += currentMonthOccurrence.amount; - } - } - } - } - - // Análise de gastos parcelados - const installmentTransactions = last3MonthsTransactions.filter( - (tx) => - tx.condition === "Parcelado" && - tx.installmentCount && - tx.installmentCount > 1, - ); - - const installmentData = installmentTransactions - .filter((tx) => tx.period === period) - .map((tx) => ({ - name: tx.name, - currentInstallment: tx.currentInstallment ?? 1, - totalInstallments: tx.installmentCount ?? 1, - amount: Math.abs(toNumber(tx.amount)), - category: tx.categoryName ?? "Outros", - })); - - const totalInstallmentAmount = installmentData.reduce( - (sum, tx) => sum + tx.amount, - 0, - ); - const futureCommitment = installmentData.reduce((sum, tx) => { - const remaining = tx.totalInstallments - tx.currentInstallment; - return sum + tx.amount * remaining; - }, 0); - - // Montar dados agregados e anonimizados - const aggregatedData = { - month: period, - totalIncome: currentIncome, - totalExpense: currentExpense, - balance: currentIncome - currentExpense, - - // Tendência de 3 meses - threeMonthTrend: { - periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period], - incomes: [ - threeMonthsAgoIncome, - twoMonthsAgoIncome, - previousIncome, - currentIncome, - ], - expenses: [ - threeMonthsAgoExpense, - twoMonthsAgoExpense, - previousExpense, - currentExpense, - ], - avgIncome: - (threeMonthsAgoIncome + - twoMonthsAgoIncome + - previousIncome + - currentIncome) / - 4, - avgExpense: - (threeMonthsAgoExpense + - twoMonthsAgoExpense + - previousExpense + - currentExpense) / - 4, - trend: - currentExpense > previousExpense && - previousExpense > twoMonthsAgoExpense - ? "crescente" - : currentExpense < previousExpense && - previousExpense < twoMonthsAgoExpense - ? "decrescente" - : "estável", - }, - - previousMonthIncome: previousIncome, - previousMonthExpense: previousExpense, - monthOverMonthIncomeChange: - Math.abs(previousIncome) > 0.01 - ? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100 - : 0, - monthOverMonthExpenseChange: - Math.abs(previousExpense) > 0.01 - ? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100 - : 0, - savingsRate: - currentIncome > 0.01 - ? ((currentIncome - currentExpense) / currentIncome) * 100 - : 0, - topExpenseCategories: expensesByCategory.map( - (cat: { categoryName: string; total: unknown }) => ({ - category: cat.categoryName, - amount: Math.abs(toNumber(cat.total)), - percentageOfTotal: - currentExpense > 0 - ? (Math.abs(toNumber(cat.total)) / currentExpense) * 100 - : 0, - }), - ), - budgets: budgetsData.map( - (b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({ - category: b.categoryName, - budgetAmount: toNumber(b.budgetAmount), - spent: Math.abs(toNumber(b.spent)), - usagePercentage: - toNumber(b.budgetAmount) > 0 - ? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100 - : 0, - }), - ), - creditCards: { - totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0), - cardCount: toNumber(cardsData[0]?.cardCount ?? 0), - }, - accounts: { - totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0), - accountCount: toNumber(accountsData[0]?.accountCount ?? 0), - }, - avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0), - transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0), - dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({ - dayOfWeek: - ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A", - total, - })), - paymentMethodsBreakdown: paymentMethodsData.map( - (pm: { paymentMethod: string | null; total: unknown }) => ({ - method: pm.paymentMethod, - total: toNumber(pm.total), - percentage: - currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0, - }), - ), - - // Análise de recorrência - recurringExpenses: { - count: recurringExpenses.length, - total: totalRecurring, - percentageOfTotal: - currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, - topRecurring: recurringExpenses - .sort((a, b) => b.avgAmount - a.avgAmount) - .slice(0, 5) - .map((r) => ({ - name: r.name, - avgAmount: r.avgAmount, - frequency: r.frequency, - })), - predictability: - currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, - }, - - // Análise de parcelamentos - installments: { - currentMonthInstallments: installmentData.length, - totalInstallmentAmount, - percentageOfExpenses: - currentExpense > 0 - ? (totalInstallmentAmount / currentExpense) * 100 - : 0, - futureCommitment, - topInstallments: installmentData - .sort((a, b) => b.amount - a.amount) - .slice(0, 5) - .map((i) => ({ - name: i.name, - current: i.currentInstallment, - total: i.totalInstallments, - amount: i.amount, - category: i.category, - remaining: i.totalInstallments - i.currentInstallment, - })), - }, - }; - - return aggregatedData; -} - -/** - * Gera insights usando IA - */ -export async function generateInsightsAction( - period: string, - modelId: string, -): Promise> { - try { - const user = await getUser(); - - // Validar modelo - verificar se existe na lista ou se é um modelo customizado - const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId); - - // Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido - const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test( - modelId, - ); - if (!selectedModel && !isOpenRouterFormat) { - return { - success: false, - error: "Modelo inválido.", - }; - } - - // Agregar dados - const aggregatedData = await aggregateMonthData(user.id, period); - - // Selecionar provider - let model: ReturnType; - - // Se o modelo tem "/" é OpenRouter (formato: provider/model) - if (isOpenRouterFormat && !selectedModel) { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { - return { - success: false, - error: - "OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env", - }; - } - - const openrouter = createOpenRouter({ - apiKey, - }); - model = openrouter.chat(modelId); - } else if (selectedModel?.provider === "openai") { - model = openai(modelId); - } else if (selectedModel?.provider === "anthropic") { - model = anthropic(modelId); - } else if (selectedModel?.provider === "google") { - model = google(modelId); - } else { - return { - success: false, - error: "Provider de modelo não suportado.", - }; - } - - // Chamar AI SDK - const result = await generateObject({ - model, - schema: InsightsResponseSchema, - system: INSIGHTS_SYSTEM_PROMPT, - prompt: `Analise os seguintes dados financeiros agregados do período ${period}. - -Dados agregados: -${JSON.stringify(aggregatedData, null, 2)} - -DADOS IMPORTANTES PARA SUA ANÁLISE: - -**Tendência de 3 meses:** -- Os dados incluem tendência dos últimos 3 meses (threeMonthTrend) -- Use isso para identificar padrões crescentes, decrescentes ou estáveis -- Compare o mês atual com a média dos 3 meses - -**Análise de Recorrência:** -- Gastos recorrentes representam ${aggregatedData.recurringExpenses.percentageOfTotal.toFixed(1)}% das despesas -- ${aggregatedData.recurringExpenses.count} gastos identificados como recorrentes -- Use isso para avaliar previsibilidade e oportunidades de otimização - -**Gastos Parcelados:** -- ${aggregatedData.installments.currentMonthInstallments} parcelas ativas no mês -- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)} -- Use isso para alertas sobre comprometimento de renda futura - -Organize suas observações nas 4 categorias especificadas no prompt do sistema: -1. Comportamentos Observados (behaviors): 3-6 itens -2. Gatilhos de Consumo (triggers): 3-6 itens -3. Recomendações Práticas (recommendations): 3-6 itens -4. Melhorias Sugeridas (improvements): 3-6 itens - -Cada item deve ser conciso, direto e acionável. Use os novos dados para dar contexto temporal e identificar padrões mais profundos. - -Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`, - }); - - // Validar resposta - const validatedData = InsightsResponseSchema.parse(result.object); - - return { - success: true, - data: validatedData, - }; - } catch (error) { - console.error("Error generating insights:", error); - return { - success: false, - error: "Erro ao gerar insights. Tente novamente.", - }; - } -} - -/** - * Salva insights gerados no banco de dados - */ -export async function saveInsightsAction( - period: string, - modelId: string, - data: InsightsResponse, -): Promise> { - try { - const user = await getUser(); - - // Verificar se já existe um insight salvo para este período - const existing = await db - .select() - .from(insightsSalvos) - .where( - and( - eq(insightsSalvos.userId, user.id), - eq(insightsSalvos.period, period), - ), - ) - .limit(1); - - if (existing.length > 0) { - // Atualizar existente - const updated = await db - .update(insightsSalvos) - .set({ - modelId, - data: JSON.stringify(data), - updatedAt: new Date(), - }) - .where( - and( - eq(insightsSalvos.userId, user.id), - eq(insightsSalvos.period, period), - ), - ) - .returning({ - id: insightsSalvos.id, - createdAt: insightsSalvos.createdAt, - }); - - const updatedRecord = updated[0]; - if (!updatedRecord) { - return { - success: false, - error: "Falha ao atualizar a análise. Tente novamente.", - }; - } - - return { - success: true, - data: { - id: updatedRecord.id, - createdAt: updatedRecord.createdAt, - }, - }; - } - - // Criar novo - const result = await db - .insert(insightsSalvos) - .values({ - userId: user.id, - period, - modelId, - data: JSON.stringify(data), - }) - .returning({ - id: insightsSalvos.id, - createdAt: insightsSalvos.createdAt, - }); - - const insertedRecord = result[0]; - if (!insertedRecord) { - return { - success: false, - error: "Falha ao salvar a análise. Tente novamente.", - }; - } - - return { - success: true, - data: { - id: insertedRecord.id, - createdAt: insertedRecord.createdAt, - }, - }; - } catch (error) { - console.error("Error saving insights:", error); - return { - success: false, - error: "Erro ao salvar análise. Tente novamente.", - }; - } -} - -/** - * Carrega insights salvos do banco de dados - */ -export async function loadSavedInsightsAction(period: string): Promise< - ActionResult<{ - insights: InsightsResponse; - modelId: string; - createdAt: Date; - } | null> -> { - try { - const user = await getUser(); - - const result = await db - .select() - .from(insightsSalvos) - .where( - and( - eq(insightsSalvos.userId, user.id), - eq(insightsSalvos.period, period), - ), - ) - .limit(1); - - if (result.length === 0) { - return { - success: true, - data: null, - }; - } - - const saved = result[0]; - if (!saved) { - return { - success: true, - data: null, - }; - } - - const insights = InsightsResponseSchema.parse(JSON.parse(saved.data)); - - return { - success: true, - data: { - insights, - modelId: saved.modelId, - createdAt: saved.createdAt, - }, - }; - } catch (error) { - console.error("Error loading saved insights:", error); - return { - success: false, - error: "Erro ao carregar análise salva. Tente novamente.", - }; - } -} - -/** - * Remove insights salvos do banco de dados - */ -export async function deleteSavedInsightsAction( - period: string, -): Promise> { - try { - const user = await getUser(); - - await db - .delete(insightsSalvos) - .where( - and( - eq(insightsSalvos.userId, user.id), - eq(insightsSalvos.period, period), - ), - ); - - return { - success: true, - data: undefined, - }; - } catch (error) { - console.error("Error deleting saved insights:", error); - return { - success: false, - error: "Erro ao remover análise. Tente novamente.", - }; - } -} diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts deleted file mode 100644 index e28b30c..0000000 --- a/app/(dashboard)/lancamentos/actions.ts +++ /dev/null @@ -1,1683 +0,0 @@ -"use server"; - -import { randomUUID } from "node:crypto"; -import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; -import { z } from "zod"; -import { - cartoes, - categorias, - contas, - lancamentos, - pagadores, -} from "@/db/schema"; -import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; -import type { ActionResult } from "@/lib/actions/types"; -import { getUser } from "@/lib/auth/server"; -import { - INITIAL_BALANCE_CONDITION, - INITIAL_BALANCE_NOTE, - INITIAL_BALANCE_PAYMENT_METHOD, - INITIAL_BALANCE_TRANSACTION_TYPE, -} from "@/lib/contas/constants"; -import { db } from "@/lib/db"; -import { - LANCAMENTO_CONDITIONS, - LANCAMENTO_PAYMENT_METHODS, - LANCAMENTO_TRANSACTION_TYPES, -} from "@/lib/lancamentos/constants"; -import { - buildEntriesByPagador, - sendPagadorAutoEmails, -} from "@/lib/pagadores/notifications"; -import { noteSchema, uuidSchema } from "@/lib/schemas/common"; -import { formatDecimalForDbRequired } from "@/lib/utils/currency"; -import { getTodayDate, parseLocalDateString } from "@/lib/utils/date"; - -// ============================================================================ -// Authorization Validation Functions -// ============================================================================ - -async function validatePagadorOwnership( - userId: string, - pagadorId: string | null | undefined, -): Promise { - if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar - - const pagador = await db.query.pagadores.findFirst({ - where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)), - }); - - return !!pagador; -} - -async function validateCategoriaOwnership( - userId: string, - categoriaId: string | null | undefined, -): Promise { - if (!categoriaId) return true; - - const categoria = await db.query.categorias.findFirst({ - where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), - }); - - return !!categoria; -} - -async function validateContaOwnership( - userId: string, - contaId: string | null | undefined, -): Promise { - if (!contaId) return true; - - const conta = await db.query.contas.findFirst({ - where: and(eq(contas.id, contaId), eq(contas.userId, userId)), - }); - - return !!conta; -} - -async function validateCartaoOwnership( - userId: string, - cartaoId: string | null | undefined, -): Promise { - if (!cartaoId) return true; - - const cartao = await db.query.cartoes.findFirst({ - where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)), - }); - - return !!cartao; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -const resolvePeriod = (purchaseDate: string, period?: string | null) => { - if (period && /^\d{4}-\d{2}$/.test(period)) { - return period; - } - - const date = parseLocalDateString(purchaseDate); - if (Number.isNaN(date.getTime())) { - throw new Error("Data da transação inválida."); - } - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; -}; - -const baseFields = z.object({ - purchaseDate: z - .string({ message: "Informe a data da transação." }) - .trim() - .refine((value) => !Number.isNaN(new Date(value).getTime()), { - message: "Data da transação inválida.", - }), - period: z - .string() - .trim() - .regex(/^(\d{4})-(\d{2})$/, { - message: "Selecione um período válido.", - }) - .optional(), - name: z - .string({ message: "Informe o estabelecimento." }) - .trim() - .min(1, "Informe o estabelecimento."), - transactionType: z - .enum(LANCAMENTO_TRANSACTION_TYPES, { - message: "Selecione um tipo de transação válido.", - }) - .default(LANCAMENTO_TRANSACTION_TYPES[0]), - amount: z.coerce - .number({ message: "Informe o valor da transação." }) - .min(0, "Informe um valor maior ou igual a zero."), - condition: z.enum(LANCAMENTO_CONDITIONS, { - message: "Selecione uma condição válida.", - }), - paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS, { - message: "Selecione uma forma de pagamento válida.", - }), - pagadorId: uuidSchema("Pagador").nullable().optional(), - secondaryPagadorId: uuidSchema("Pagador secundário").optional(), - isSplit: z.boolean().optional().default(false), - primarySplitAmount: z.coerce.number().min(0).optional(), - secondarySplitAmount: z.coerce.number().min(0).optional(), - contaId: uuidSchema("Conta").nullable().optional(), - cartaoId: uuidSchema("Cartão").nullable().optional(), - categoriaId: uuidSchema("Categoria").nullable().optional(), - note: noteSchema, - installmentCount: z.coerce - .number() - .int() - .min(1, "Selecione uma quantidade válida.") - .max(60, "Selecione uma quantidade válida.") - .optional(), - recurrenceCount: z.coerce - .number() - .int() - .min(1, "Selecione uma recorrência válida.") - .max(60, "Selecione uma recorrência válida.") - .optional(), - dueDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de vencimento válida.", - }) - .optional(), - boletoPaymentDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de pagamento válida.", - }) - .optional(), - isSettled: z.boolean().nullable().optional(), -}); - -const refineLancamento = ( - data: z.infer & { id?: string }, - ctx: z.RefinementCtx, -) => { - if (!data.categoriaId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["categoriaId"], - message: "Selecione uma categoria.", - }); - } - - if (data.paymentMethod === "Cartão de crédito") { - if (!data.cartaoId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["cartaoId"], - message: "Selecione o cartão.", - }); - } - } else if (!data.contaId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["contaId"], - message: "Selecione a conta.", - }); - } - - if (data.condition === "Parcelado") { - if (!data.installmentCount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["installmentCount"], - message: "Informe a quantidade de parcelas.", - }); - } else if (data.installmentCount < 2) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["installmentCount"], - message: "Selecione pelo menos duas parcelas.", - }); - } - } - - if (data.condition === "Recorrente") { - if (!data.recurrenceCount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["recurrenceCount"], - message: "Informe por quantos meses a recorrência acontecerá.", - }); - } else if (data.recurrenceCount < 2) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["recurrenceCount"], - message: "A recorrência deve ter ao menos dois meses.", - }); - } - } - - if (data.isSplit) { - if (!data.pagadorId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["pagadorId"], - message: "Selecione o pagador principal para dividir o lançamento.", - }); - } - - if (!data.secondaryPagadorId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["secondaryPagadorId"], - message: "Selecione o pagador secundário para dividir o lançamento.", - }); - } else if (data.pagadorId && data.secondaryPagadorId === data.pagadorId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["secondaryPagadorId"], - message: "Escolha um pagador diferente para dividir o lançamento.", - }); - } - - // Validate custom split amounts sum to total - if ( - data.primarySplitAmount !== undefined && - data.secondarySplitAmount !== undefined - ) { - const sum = data.primarySplitAmount + data.secondarySplitAmount; - const total = Math.abs(data.amount); - // Allow 1 cent tolerance for rounding differences - if (Math.abs(sum - total) > 0.01) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["primarySplitAmount"], - message: "A soma das divisões deve ser igual ao valor total.", - }); - } - } - } -}; - -const createSchema = baseFields.superRefine(refineLancamento); -const updateSchema = baseFields - .extend({ - id: uuidSchema("Lançamento"), - }) - .superRefine(refineLancamento); - -const deleteSchema = z.object({ - id: uuidSchema("Lançamento"), -}); - -const toggleSettlementSchema = z.object({ - id: uuidSchema("Lançamento"), - value: z.boolean({ - message: "Informe o status de pagamento.", - }), -}); - -type BaseInput = z.infer; -type CreateInput = z.infer; -type UpdateInput = z.infer; -type DeleteInput = z.infer; -type ToggleSettlementInput = z.infer; - -const revalidate = () => revalidateForEntity("lancamentos"); - -const resolveUserLabel = (user: { - name?: string | null; - email?: string | null; -}) => { - if (user?.name && user.name.trim().length > 0) { - return user.name; - } - if (user?.email && user.email.trim().length > 0) { - return user.email; - } - return "OpenMonetis"; -}; - -type InitialCandidate = { - note: string | null; - transactionType: string | null; - condition: string | null; - paymentMethod: string | null; -}; - -const isInitialBalanceLancamento = (record?: InitialCandidate | null) => - !!record && - record.note === INITIAL_BALANCE_NOTE && - record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE && - record.condition === INITIAL_BALANCE_CONDITION && - record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD; - -const centsToDecimalString = (value: number) => { - const decimal = value / 100; - const formatted = decimal.toFixed(2); - return Object.is(decimal, -0) ? "0.00" : formatted; -}; - -const splitAmount = (totalCents: number, parts: number) => { - if (parts <= 0) { - return []; - } - - const base = Math.trunc(totalCents / parts); - const remainder = totalCents % parts; - - return Array.from( - { length: parts }, - (_, index) => base + (index < remainder ? 1 : 0), - ); -}; - -const addMonthsToPeriod = (period: string, offset: number) => { - const [yearStr, monthStr] = period.split("-"); - const baseYear = Number(yearStr); - const baseMonth = Number(monthStr); - - if (!baseYear || !baseMonth) { - throw new Error("Período inválido."); - } - - const date = new Date(baseYear, baseMonth - 1, 1); - date.setMonth(date.getMonth() + offset); - - const nextYear = date.getFullYear(); - const nextMonth = String(date.getMonth() + 1).padStart(2, "0"); - return `${nextYear}-${nextMonth}`; -}; - -const addMonthsToDate = (value: Date, offset: number) => { - const result = new Date(value); - const originalDay = result.getDate(); - - result.setDate(1); - result.setMonth(result.getMonth() + offset); - - const lastDay = new Date( - result.getFullYear(), - result.getMonth() + 1, - 0, - ).getDate(); - - result.setDate(Math.min(originalDay, lastDay)); - return result; -}; - -type Share = { - pagadorId: string | null; - amountCents: number; -}; - -const buildShares = ({ - totalCents, - pagadorId, - isSplit, - secondaryPagadorId, - primarySplitAmountCents, - secondarySplitAmountCents, -}: { - totalCents: number; - pagadorId: string | null; - isSplit: boolean; - secondaryPagadorId?: string; - primarySplitAmountCents?: number; - secondarySplitAmountCents?: number; -}): Share[] => { - if (isSplit) { - if (!pagadorId || !secondaryPagadorId) { - throw new Error("Configuração de divisão inválida para o lançamento."); - } - - // Use custom split amounts if provided - if ( - primarySplitAmountCents !== undefined && - secondarySplitAmountCents !== undefined - ) { - return [ - { pagadorId, amountCents: primarySplitAmountCents }, - { - pagadorId: secondaryPagadorId, - amountCents: secondarySplitAmountCents, - }, - ]; - } - - // Fallback to equal split - const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); - return [ - { pagadorId, amountCents: primaryAmount }, - { pagadorId: secondaryPagadorId, amountCents: secondaryAmount }, - ]; - } - - return [{ pagadorId, amountCents: totalCents }]; -}; - -type BuildLancamentoRecordsParams = { - data: BaseInput; - userId: string; - period: string; - purchaseDate: Date; - dueDate: Date | null; - boletoPaymentDate: Date | null; - shares: Share[]; - amountSign: 1 | -1; - shouldNullifySettled: boolean; - seriesId: string | null; -}; - -type LancamentoInsert = typeof lancamentos.$inferInsert; - -const buildLancamentoRecords = ({ - data, - userId, - period, - purchaseDate, - dueDate, - boletoPaymentDate, - shares, - amountSign, - shouldNullifySettled, - seriesId, -}: BuildLancamentoRecordsParams): LancamentoInsert[] => { - const records: LancamentoInsert[] = []; - - const basePayload = { - name: data.name, - transactionType: data.transactionType, - condition: data.condition, - paymentMethod: data.paymentMethod, - note: data.note ?? null, - contaId: data.contaId ?? null, - cartaoId: data.cartaoId ?? null, - categoriaId: data.categoriaId ?? null, - recurrenceCount: null as number | null, - installmentCount: null as number | null, - currentInstallment: null as number | null, - isDivided: data.isSplit ?? false, - userId, - seriesId, - }; - - const resolveSettledValue = (cycleIndex: number) => { - if (shouldNullifySettled) { - return null; - } - const initialSettled = data.isSettled ?? false; - if (data.condition === "Parcelado" || data.condition === "Recorrente") { - return cycleIndex === 0 ? initialSettled : false; - } - return initialSettled; - }; - - if (data.condition === "Parcelado") { - const installmentTotal = data.installmentCount ?? 0; - const amountsByShare = shares.map((share) => - splitAmount(share.amountCents, installmentTotal), - ); - - for ( - let installment = 0; - installment < installmentTotal; - installment += 1 - ) { - const installmentPeriod = addMonthsToPeriod(period, installment); - const installmentDueDate = dueDate - ? addMonthsToDate(dueDate, installment) - : null; - - shares.forEach((share, shareIndex) => { - const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; - const settled = resolveSettledValue(installment); - records.push({ - ...basePayload, - amount: centsToDecimalString(amountCents * amountSign), - pagadorId: share.pagadorId, - purchaseDate: purchaseDate, - period: installmentPeriod, - isSettled: settled, - installmentCount: installmentTotal, - currentInstallment: installment + 1, - recurrenceCount: null, - dueDate: installmentDueDate, - boletoPaymentDate: - data.paymentMethod === "Boleto" && settled - ? boletoPaymentDate - : null, - }); - }); - } - - return records; - } - - if (data.condition === "Recorrente") { - const recurrenceTotal = data.recurrenceCount ?? 0; - - for (let index = 0; index < recurrenceTotal; index += 1) { - const recurrencePeriod = addMonthsToPeriod(period, index); - const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index); - const recurrenceDueDate = dueDate - ? addMonthsToDate(dueDate, index) - : null; - - shares.forEach((share) => { - const settled = resolveSettledValue(index); - records.push({ - ...basePayload, - amount: centsToDecimalString(share.amountCents * amountSign), - pagadorId: share.pagadorId, - purchaseDate: recurrencePurchaseDate, - period: recurrencePeriod, - isSettled: settled, - recurrenceCount: recurrenceTotal, - dueDate: recurrenceDueDate, - boletoPaymentDate: - data.paymentMethod === "Boleto" && settled - ? boletoPaymentDate - : null, - }); - }); - } - - return records; - } - - shares.forEach((share) => { - const settled = resolveSettledValue(0); - records.push({ - ...basePayload, - amount: centsToDecimalString(share.amountCents * amountSign), - pagadorId: share.pagadorId, - purchaseDate, - period, - isSettled: settled, - dueDate, - boletoPaymentDate: - data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, - }); - }); - - return records; -}; - -export async function createLancamentoAction( - input: CreateInput, -): Promise { - try { - const user = await getUser(); - const data = createSchema.parse(input); - - // Validar propriedade dos recursos referenciados - if (data.pagadorId) { - const isValid = await validatePagadorOwnership(user.id, data.pagadorId); - if (!isValid) { - return { - success: false, - error: "Pagador não encontrado ou sem permissão.", - }; - } - } - - if (data.secondaryPagadorId) { - const isValid = await validatePagadorOwnership( - user.id, - data.secondaryPagadorId, - ); - if (!isValid) { - return { - success: false, - error: "Pagador secundário não encontrado ou sem permissão.", - }; - } - } - - if (data.categoriaId) { - const isValid = await validateCategoriaOwnership( - user.id, - data.categoriaId, - ); - if (!isValid) { - return { success: false, error: "Categoria não encontrada." }; - } - } - - if (data.contaId) { - const isValid = await validateContaOwnership(user.id, data.contaId); - if (!isValid) { - return { success: false, error: "Conta não encontrada." }; - } - } - - if (data.cartaoId) { - const isValid = await validateCartaoOwnership(user.id, data.cartaoId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } - - const period = resolvePeriod(data.purchaseDate, data.period); - const purchaseDate = parseLocalDateString(data.purchaseDate); - const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null; - const shouldSetBoletoPaymentDate = - data.paymentMethod === "Boleto" && (data.isSettled ?? false); - const boletoPaymentDate = shouldSetBoletoPaymentDate - ? data.boletoPaymentDate - ? parseLocalDateString(data.boletoPaymentDate) - : getTodayDate() - : null; - - const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; - const totalCents = Math.round(Math.abs(data.amount) * 100); - const shouldNullifySettled = data.paymentMethod === "Cartão de crédito"; - - const shares = buildShares({ - totalCents, - pagadorId: data.pagadorId ?? null, - isSplit: data.isSplit ?? false, - secondaryPagadorId: data.secondaryPagadorId, - primarySplitAmountCents: data.primarySplitAmount - ? Math.round(data.primarySplitAmount * 100) - : undefined, - secondarySplitAmountCents: data.secondarySplitAmount - ? Math.round(data.secondarySplitAmount * 100) - : undefined, - }); - - const isSeriesLancamento = - data.condition === "Parcelado" || data.condition === "Recorrente"; - const seriesId = isSeriesLancamento ? randomUUID() : null; - - const records = buildLancamentoRecords({ - data, - userId: user.id, - period, - purchaseDate, - dueDate, - shares, - amountSign, - shouldNullifySettled, - boletoPaymentDate, - seriesId, - }); - - if (!records.length) { - throw new Error("Não foi possível criar os lançamentos solicitados."); - } - - await db.transaction(async (tx: typeof db) => { - await tx.insert(lancamentos).values(records); - }); - - const notificationEntries = buildEntriesByPagador( - records.map((record) => ({ - pagadorId: record.pagadorId ?? null, - name: record.name ?? null, - amount: record.amount ?? null, - transactionType: record.transactionType ?? null, - paymentMethod: record.paymentMethod ?? null, - condition: record.condition ?? null, - purchaseDate: record.purchaseDate ?? null, - period: record.period ?? null, - note: record.note ?? null, - })), - ); - - if (notificationEntries.size > 0) { - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "created", - entriesByPagador: notificationEntries, - }); - } - - revalidate(); - - return { success: true, message: "Lançamento criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } -} - -export async function updateLancamentoAction( - input: UpdateInput, -): Promise { - try { - const user = await getUser(); - const data = updateSchema.parse(input); - - // Validar propriedade dos recursos referenciados - if (data.pagadorId) { - const isValid = await validatePagadorOwnership(user.id, data.pagadorId); - if (!isValid) { - return { - success: false, - error: "Pagador não encontrado ou sem permissão.", - }; - } - } - - if (data.secondaryPagadorId) { - const isValid = await validatePagadorOwnership( - user.id, - data.secondaryPagadorId, - ); - if (!isValid) { - return { - success: false, - error: "Pagador secundário não encontrado ou sem permissão.", - }; - } - } - - if (data.categoriaId) { - const isValid = await validateCategoriaOwnership( - user.id, - data.categoriaId, - ); - if (!isValid) { - return { success: false, error: "Categoria não encontrada." }; - } - } - - if (data.contaId) { - const isValid = await validateContaOwnership(user.id, data.contaId); - if (!isValid) { - return { success: false, error: "Conta não encontrada." }; - } - } - - if (data.cartaoId) { - const isValid = await validateCartaoOwnership(user.id, data.cartaoId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } - - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - note: true, - transactionType: true, - condition: true, - paymentMethod: true, - contaId: true, - categoriaId: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - with: { - categoria: { - columns: { - name: true, - }, - }, - }, - }); - - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } - - // Bloquear edição de lançamentos com categorias protegidas - // Nota: "Transferência interna" foi removida para permitir correção de valores - const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; - if ( - existing.categoria?.name && - categoriasProtegidasEdicao.includes(existing.categoria.name) - ) { - return { - success: false, - error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser editados.`, - }; - } - - const period = resolvePeriod(data.purchaseDate, data.period); - const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; - const amountCents = Math.round(Math.abs(data.amount) * 100); - const normalizedAmount = centsToDecimalString(amountCents * amountSign); - const normalizedSettled = - data.paymentMethod === "Cartão de crédito" - ? null - : (data.isSettled ?? false); - const shouldSetBoletoPaymentDate = - data.paymentMethod === "Boleto" && Boolean(normalizedSettled); - const boletoPaymentDateValue = shouldSetBoletoPaymentDate - ? data.boletoPaymentDate - ? parseLocalDateString(data.boletoPaymentDate) - : getTodayDate() - : null; - - await db - .update(lancamentos) - .set({ - name: data.name, - purchaseDate: parseLocalDateString(data.purchaseDate), - transactionType: data.transactionType, - amount: normalizedAmount, - condition: data.condition, - paymentMethod: data.paymentMethod, - pagadorId: data.pagadorId ?? null, - contaId: data.contaId ?? null, - cartaoId: data.cartaoId ?? null, - categoriaId: data.categoriaId ?? null, - note: data.note ?? null, - isSettled: normalizedSettled, - installmentCount: data.installmentCount ?? null, - recurrenceCount: data.recurrenceCount ?? null, - dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null, - boletoPaymentDate: boletoPaymentDateValue, - period, - }) - .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); - - if (isInitialBalanceLancamento(existing) && existing?.contaId) { - const updatedInitialBalance = formatDecimalForDbRequired( - Math.abs(data.amount ?? 0), - ); - await db - .update(contas) - .set({ initialBalance: updatedInitialBalance }) - .where( - and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)), - ); - } - - revalidate(); - - return { success: true, message: "Lançamento atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } -} - -export async function deleteLancamentoAction( - input: DeleteInput, -): Promise { - try { - const user = await getUser(); - const data = deleteSchema.parse(input); - - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - name: true, - pagadorId: true, - amount: true, - transactionType: true, - paymentMethod: true, - condition: true, - purchaseDate: true, - period: true, - note: true, - categoriaId: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - with: { - categoria: { - columns: { - name: true, - }, - }, - }, - }); - - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } - - // Bloquear remoção de lançamentos com categorias protegidas - // Nota: "Transferência interna" foi removida para permitir correção/exclusão - const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; - if ( - existing.categoria?.name && - categoriasProtegidasRemocao.includes(existing.categoria.name) - ) { - return { - success: false, - error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser removidos.`, - }; - } - - await db - .delete(lancamentos) - .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); - - if (existing.pagadorId) { - const notificationEntries = buildEntriesByPagador([ - { - pagadorId: existing.pagadorId, - name: existing.name ?? null, - amount: existing.amount ?? null, - transactionType: existing.transactionType ?? null, - paymentMethod: existing.paymentMethod ?? null, - condition: existing.condition ?? null, - purchaseDate: existing.purchaseDate ?? null, - period: existing.period ?? null, - note: existing.note ?? null, - }, - ]); - - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "deleted", - entriesByPagador: notificationEntries, - }); - } - - revalidate(); - - return { success: true, message: "Lançamento removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } -} - -export async function toggleLancamentoSettlementAction( - input: ToggleSettlementInput, -): Promise { - try { - const user = await getUser(); - const data = toggleSettlementSchema.parse(input); - - const existing = await db.query.lancamentos.findFirst({ - columns: { id: true, paymentMethod: true }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - }); - - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } - - if (existing.paymentMethod === "Cartão de crédito") { - return { - success: false, - error: "Pagamentos com cartão são conciliados automaticamente.", - }; - } - - const isBoleto = existing.paymentMethod === "Boleto"; - const boletoPaymentDate = isBoleto - ? data.value - ? getTodayDate() - : null - : null; - - await db - .update(lancamentos) - .set({ - isSettled: data.value, - boletoPaymentDate, - }) - .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); - - revalidate(); - - return { - success: true, - message: data.value - ? "Lançamento marcado como pago." - : "Pagamento desfeito com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } -} - -const deleteBulkSchema = z.object({ - id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { - message: "Escopo de ação inválido.", - }), -}); - -type DeleteBulkInput = z.infer; - -export async function deleteLancamentoBulkAction( - input: DeleteBulkInput, -): Promise { - try { - const user = await getUser(); - const data = deleteBulkSchema.parse(input); - - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - name: true, - seriesId: true, - period: true, - condition: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - }); - - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } - - if (!existing.seriesId) { - return { - success: false, - error: "Este lançamento não faz parte de uma série.", - }; - } - - if (data.scope === "current") { - await db - .delete(lancamentos) - .where( - and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - ); - - revalidate(); - return { success: true, message: "Lançamento removido com sucesso." }; - } - - if (data.scope === "future") { - await db - .delete(lancamentos) - .where( - and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id), - sql`${lancamentos.period} >= ${existing.period}`, - ), - ); - - revalidate(); - return { - success: true, - message: "Lançamentos removidos com sucesso.", - }; - } - - if (data.scope === "all") { - await db - .delete(lancamentos) - .where( - and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id), - ), - ); - - revalidate(); - return { - success: true, - message: "Todos os lançamentos da série foram removidos.", - }; - } - - return { success: false, error: "Escopo de ação inválido." }; - } catch (error) { - return handleActionError(error); - } -} - -const updateBulkSchema = z.object({ - id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { - message: "Escopo de ação inválido.", - }), - name: z - .string({ message: "Informe o estabelecimento." }) - .trim() - .min(1, "Informe o estabelecimento."), - categoriaId: uuidSchema("Categoria").nullable().optional(), - note: noteSchema, - pagadorId: uuidSchema("Pagador").nullable().optional(), - contaId: uuidSchema("Conta").nullable().optional(), - cartaoId: uuidSchema("Cartão").nullable().optional(), - amount: z.coerce - .number({ message: "Informe o valor da transação." }) - .min(0, "Informe um valor maior ou igual a zero.") - .optional(), - dueDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de vencimento válida.", - }) - .optional() - .nullable(), - boletoPaymentDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de pagamento válida.", - }) - .optional() - .nullable(), -}); - -type UpdateBulkInput = z.infer; - -export async function updateLancamentoBulkAction( - input: UpdateBulkInput, -): Promise { - try { - const user = await getUser(); - const data = updateBulkSchema.parse(input); - - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - name: true, - seriesId: true, - period: true, - condition: true, - transactionType: true, - purchaseDate: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - }); - - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } - - if (!existing.seriesId) { - return { - success: false, - error: "Este lançamento não faz parte de uma série.", - }; - } - - const baseUpdatePayload: Record = { - name: data.name, - categoriaId: data.categoriaId ?? null, - note: data.note ?? null, - pagadorId: data.pagadorId ?? null, - contaId: data.contaId ?? null, - cartaoId: data.cartaoId ?? null, - }; - - if (data.amount !== undefined) { - const amountSign: 1 | -1 = - existing.transactionType === "Despesa" ? -1 : 1; - const amountCents = Math.round(Math.abs(data.amount) * 100); - baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign); - } - - const hasDueDateUpdate = data.dueDate !== undefined; - const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; - - const baseDueDate = - hasDueDateUpdate && data.dueDate - ? parseLocalDateString(data.dueDate) - : hasDueDateUpdate - ? null - : undefined; - - const baseBoletoPaymentDate = - hasBoletoPaymentDateUpdate && data.boletoPaymentDate - ? parseLocalDateString(data.boletoPaymentDate) - : hasBoletoPaymentDateUpdate - ? null - : undefined; - - const basePurchaseDate = existing.purchaseDate ?? null; - - const buildDueDateForRecord = (recordPurchaseDate: Date | null) => { - if (!hasDueDateUpdate) { - return undefined; - } - - if (!baseDueDate) { - return null; - } - - if (!basePurchaseDate || !recordPurchaseDate) { - return baseDueDate; - } - - const monthDiff = - (recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * - 12 + - (recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); - - return addMonthsToDate(baseDueDate, monthDiff); - }; - - const applyUpdates = async ( - records: Array<{ id: string; purchaseDate: Date | null }>, - ) => { - if (records.length === 0) { - return; - } - - await db.transaction(async (tx: typeof db) => { - for (const record of records) { - const perRecordPayload: Record = { - ...baseUpdatePayload, - }; - - const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); - if (dueDateForRecord !== undefined) { - perRecordPayload.dueDate = dueDateForRecord; - } - - if (hasBoletoPaymentDateUpdate) { - perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null; - } - - await tx - .update(lancamentos) - .set(perRecordPayload) - .where( - and( - eq(lancamentos.id, record.id), - eq(lancamentos.userId, user.id), - ), - ); - } - }); - }; - - if (data.scope === "current") { - await applyUpdates([ - { - id: data.id, - purchaseDate: existing.purchaseDate ?? null, - }, - ]); - - revalidate(); - return { success: true, message: "Lançamento atualizado com sucesso." }; - } - - if (data.scope === "future") { - const futureLancamentos = await db.query.lancamentos.findMany({ - columns: { - id: true, - purchaseDate: true, - }, - where: and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id), - sql`${lancamentos.period} >= ${existing.period}`, - ), - orderBy: asc(lancamentos.purchaseDate), - }); - - await applyUpdates( - futureLancamentos.map((item) => ({ - id: item.id, - purchaseDate: item.purchaseDate ?? null, - })), - ); - - revalidate(); - return { - success: true, - message: "Lançamentos atualizados com sucesso.", - }; - } - - if (data.scope === "all") { - const allLancamentos = await db.query.lancamentos.findMany({ - columns: { - id: true, - purchaseDate: true, - }, - where: and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id), - ), - orderBy: asc(lancamentos.purchaseDate), - }); - - await applyUpdates( - allLancamentos.map((item) => ({ - id: item.id, - purchaseDate: item.purchaseDate ?? null, - })), - ); - - revalidate(); - return { - success: true, - message: "Todos os lançamentos da série foram atualizados.", - }; - } - - return { success: false, error: "Escopo de ação inválido." }; - } catch (error) { - return handleActionError(error); - } -} - -// Mass Add Schema -const massAddTransactionSchema = z.object({ - purchaseDate: z - .string({ message: "Informe a data da transação." }) - .trim() - .refine((value) => !Number.isNaN(new Date(value).getTime()), { - message: "Data da transação inválida.", - }), - name: z - .string({ message: "Informe o estabelecimento." }) - .trim() - .min(1, "Informe o estabelecimento."), - amount: z.coerce - .number({ message: "Informe o valor da transação." }) - .min(0, "Informe um valor maior ou igual a zero."), - categoriaId: uuidSchema("Categoria").nullable().optional(), - pagadorId: uuidSchema("Pagador").nullable().optional(), -}); - -const massAddSchema = z.object({ - fixedFields: z.object({ - transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(), - paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(), - condition: z.enum(LANCAMENTO_CONDITIONS).optional(), - period: z - .string() - .trim() - .regex(/^(\d{4})-(\d{2})$/, { - message: "Selecione um período válido.", - }) - .optional(), - contaId: uuidSchema("Conta").nullable().optional(), - cartaoId: uuidSchema("Cartão").nullable().optional(), - }), - transactions: z - .array(massAddTransactionSchema) - .min(1, "Adicione pelo menos uma transação."), -}); - -type MassAddInput = z.infer; - -export async function createMassLancamentosAction( - input: MassAddInput, -): Promise { - try { - const user = await getUser(); - const data = massAddSchema.parse(input); - - // Validar campos fixos - if (data.fixedFields.contaId) { - const isValid = await validateContaOwnership( - user.id, - data.fixedFields.contaId, - ); - if (!isValid) { - return { success: false, error: "Conta não encontrada." }; - } - } - - if (data.fixedFields.cartaoId) { - const isValid = await validateCartaoOwnership( - user.id, - data.fixedFields.cartaoId, - ); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } - - // Validar cada transação individual - for (let i = 0; i < data.transactions.length; i++) { - const transaction = data.transactions[i]; - - if (transaction.pagadorId) { - const isValid = await validatePagadorOwnership( - user.id, - transaction.pagadorId, - ); - if (!isValid) { - return { - success: false, - error: `Pagador não encontrado na transação ${i + 1}.`, - }; - } - } - - if (transaction.categoriaId) { - const isValid = await validateCategoriaOwnership( - user.id, - transaction.categoriaId, - ); - if (!isValid) { - return { - success: false, - error: `Categoria não encontrada na transação ${i + 1}.`, - }; - } - } - } - - // Default values for non-fixed fields - const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0]; - const defaultCondition = LANCAMENTO_CONDITIONS[0]; - const defaultPaymentMethod = LANCAMENTO_PAYMENT_METHODS[0]; - - const allRecords: LancamentoInsert[] = []; - const notificationData: Array<{ - pagadorId: string | null; - name: string | null; - amount: string | null; - transactionType: string | null; - paymentMethod: string | null; - condition: string | null; - purchaseDate: Date | null; - period: string | null; - note: string | null; - }> = []; - - // Process each transaction - for (const transaction of data.transactions) { - const transactionType = - data.fixedFields.transactionType ?? defaultTransactionType; - const condition = data.fixedFields.condition ?? defaultCondition; - const paymentMethod = - data.fixedFields.paymentMethod ?? defaultPaymentMethod; - const pagadorId = transaction.pagadorId ?? null; - const contaId = - paymentMethod === "Cartão de crédito" - ? null - : (data.fixedFields.contaId ?? null); - const cartaoId = - paymentMethod === "Cartão de crédito" - ? (data.fixedFields.cartaoId ?? null) - : null; - const categoriaId = transaction.categoriaId ?? null; - - const period = - data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate); - const purchaseDate = parseLocalDateString(transaction.purchaseDate); - const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1; - const totalCents = Math.round(Math.abs(transaction.amount) * 100); - const amount = centsToDecimalString(totalCents * amountSign); - const isSettled = paymentMethod === "Cartão de crédito" ? null : false; - - const record: LancamentoInsert = { - name: transaction.name, - purchaseDate, - period, - transactionType, - amount, - condition, - paymentMethod, - pagadorId, - contaId, - cartaoId, - categoriaId, - note: null, - installmentCount: null, - recurrenceCount: null, - currentInstallment: null, - isSettled, - isDivided: false, - dueDate: null, - boletoPaymentDate: null, - userId: user.id, - seriesId: null, - }; - - allRecords.push(record); - - notificationData.push({ - pagadorId, - name: transaction.name, - amount, - transactionType, - paymentMethod, - condition, - purchaseDate, - period, - note: null, - }); - } - - if (!allRecords.length) { - throw new Error("Não foi possível criar os lançamentos solicitados."); - } - - // Insert all records in a single transaction - await db.transaction(async (tx: typeof db) => { - await tx.insert(lancamentos).values(allRecords); - }); - - // Send notifications - const notificationEntries = buildEntriesByPagador(notificationData); - - if (notificationEntries.size > 0) { - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "created", - entriesByPagador: notificationEntries, - }); - } - - revalidate(); - - const count = allRecords.length; - return { - success: true, - message: `${count} ${ - count === 1 ? "lançamento criado" : "lançamentos criados" - } com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } -} - -// Delete multiple lancamentos at once -const deleteMultipleSchema = z.object({ - ids: z - .array(uuidSchema("Lançamento")) - .min(1, "Selecione pelo menos um lançamento."), -}); - -type DeleteMultipleInput = z.infer; - -export async function deleteMultipleLancamentosAction( - input: DeleteMultipleInput, -): Promise { - try { - const user = await getUser(); - const data = deleteMultipleSchema.parse(input); - - // Fetch all lancamentos to be deleted - const existing = await db.query.lancamentos.findMany({ - columns: { - id: true, - name: true, - pagadorId: true, - amount: true, - transactionType: true, - paymentMethod: true, - condition: true, - purchaseDate: true, - period: true, - note: true, - }, - where: and( - inArray(lancamentos.id, data.ids), - eq(lancamentos.userId, user.id), - ), - }); - - if (existing.length === 0) { - return { success: false, error: "Nenhum lançamento encontrado." }; - } - - // Delete all lancamentos - await db - .delete(lancamentos) - .where( - and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)), - ); - - // Send notifications - const notificationData = existing - .filter( - ( - item, - ): item is typeof item & { - pagadorId: NonNullable; - } => Boolean(item.pagadorId), - ) - .map((item) => ({ - pagadorId: item.pagadorId, - name: item.name ?? null, - amount: item.amount ?? null, - transactionType: item.transactionType ?? null, - paymentMethod: item.paymentMethod ?? null, - condition: item.condition ?? null, - purchaseDate: item.purchaseDate ?? null, - period: item.period ?? null, - note: item.note ?? null, - })); - - if (notificationData.length > 0) { - const notificationEntries = buildEntriesByPagador(notificationData); - - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "deleted", - entriesByPagador: notificationEntries, - }); - } - - revalidate(); - - const count = existing.length; - return { - success: true, - message: `${count} ${ - count === 1 ? "lançamento removido" : "lançamentos removidos" - } com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } -} - -// Get unique establishment names from the last 3 months -export async function getRecentEstablishmentsAction(): Promise { - try { - const user = await getUser(); - - // Calculate date 3 months ago - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - - // Fetch establishment names from the last 3 months - const results = await db - .select({ name: lancamentos.name }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - gte(lancamentos.purchaseDate, threeMonthsAgo), - ), - ) - .orderBy(desc(lancamentos.purchaseDate)); - - // Remove duplicates and filter empty names - const uniqueNames = Array.from( - new Set( - results - .map((r) => r.name) - .filter( - (name): name is string => - name != null && - name.trim().length > 0 && - !name.toLowerCase().startsWith("pagamento fatura"), - ), - ), - ); - - // Return top 50 most recent unique establishments - return uniqueNames.slice(0, 100); - } catch (error) { - console.error("Error fetching recent establishments:", error); - return []; - } -} diff --git a/app/(dashboard)/lancamentos/data.ts b/app/(dashboard)/lancamentos/data.ts deleted file mode 100644 index 2482052..0000000 --- a/app/(dashboard)/lancamentos/data.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm"; -import { - cartoes, - categorias, - contas, - lancamentos, - pagadores, -} from "@/db/schema"; -import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants"; -import { db } from "@/lib/db"; - -export async function fetchLancamentos(filters: SQL[]) { - const lancamentoRows = await db - .select({ - lancamento: lancamentos, - pagador: pagadores, - conta: contas, - cartao: cartoes, - categoria: categorias, - }) - .from(lancamentos) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .leftJoin(contas, eq(lancamentos.contaId, contas.id)) - .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) - .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where( - and( - ...filters, - // Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true - or( - ne(lancamentos.note, INITIAL_BALANCE_NOTE), - isNull(contas.excludeInitialBalanceFromIncome), - eq(contas.excludeInitialBalanceFromIncome, false), - ), - ), - ) - .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); - - // Transformar resultado para o formato esperado - return lancamentoRows.map((row) => ({ - ...row.lancamento, - pagador: row.pagador, - conta: row.conta, - cartao: row.cartao, - categoria: row.categoria, - })); -} diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx deleted file mode 100644 index 5cd3fad..0000000 --- a/app/(dashboard)/lancamentos/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; -import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; -import MonthNavigation from "@/components/month-picker/month-navigation"; -import { getUserId } from "@/lib/auth/server"; -import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - mapLancamentosData, - type ResolvedSearchParams, -} from "@/lib/lancamentos/page-helpers"; -import { parsePeriodParam } from "@/lib/utils/period"; -import { getRecentEstablishmentsAction } from "./actions"; -import { fetchLancamentos } from "./data"; - -type PageSearchParams = Promise; - -type PageProps = { - searchParams?: PageSearchParams; -}; - -export default async function Page({ searchParams }: PageProps) { - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw); - - const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - - const [filterSources, userPreferences] = await Promise.all([ - fetchLancamentoFilterSources(userId), - fetchUserPreferences(userId), - ]); - - const sluggedFilters = buildSluggedFilters(filterSources); - const slugMaps = buildSlugMaps(sluggedFilters); - - const filters = buildLancamentoWhere({ - userId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - }); - - const lancamentoRows = await fetchLancamentos(filters); - const lancamentosData = mapLancamentosData(lancamentoRows); - - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); - - const estabelecimentos = await getRecentEstablishmentsAction(); - - return ( -
- - -
- ); -} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx deleted file mode 100644 index e6593f1..0000000 --- a/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { FontProvider } from "@/components/font-provider"; -import { AppNavbar } from "@/components/navigation/navbar/app-navbar"; -import { PrivacyProvider } from "@/components/privacy-provider"; -import { getUserSession } from "@/lib/auth/server"; -import { fetchDashboardNotifications } from "@/lib/dashboard/notifications"; -import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { fetchUserFontPreferences } from "@/lib/preferences/fonts"; -import { parsePeriodParam } from "@/lib/utils/period"; -import { fetchPendingInboxCount } from "./pre-lancamentos/data"; - -export default async function DashboardLayout({ - children, - searchParams, -}: Readonly<{ - children: React.ReactNode; - searchParams?: Promise>; -}>) { - const session = await getUserSession(); - const pagadoresList = await fetchPagadoresWithAccess(session.user.id); - - // Encontrar o pagador admin do usuário - const adminPagador = pagadoresList.find( - (p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id, - ); - - // Buscar notificações para o período atual - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = resolvedSearchParams?.periodo; - const singlePeriodoParam = - typeof periodoParam === "string" - ? periodoParam - : Array.isArray(periodoParam) - ? periodoParam[0] - : null; - const { period: currentPeriod } = parsePeriodParam( - singlePeriodoParam ?? null, - ); - const notificationsSnapshot = await fetchDashboardNotifications( - session.user.id, - currentPeriod, - ); - - // Buscar contagem de pré-lançamentos pendentes e preferências de fonte - const [preLancamentosCount, fontPrefs] = await Promise.all([ - fetchPendingInboxCount(session.user.id), - fetchUserFontPreferences(session.user.id), - ]); - - return ( - - - -
-
-
-
- {children} -
-
-
- - - ); -} diff --git a/app/(dashboard)/orcamentos/actions.ts b/app/(dashboard)/orcamentos/actions.ts deleted file mode 100644 index 8031882..0000000 --- a/app/(dashboard)/orcamentos/actions.ts +++ /dev/null @@ -1,274 +0,0 @@ -"use server"; - -import { and, eq, ne } from "drizzle-orm"; -import { z } from "zod"; -import { categorias, orcamentos } from "@/db/schema"; -import { - type ActionResult, - handleActionError, - revalidateForEntity, -} from "@/lib/actions/helpers"; -import { getUser } from "@/lib/auth/server"; -import { db } from "@/lib/db"; -import { periodSchema, uuidSchema } from "@/lib/schemas/common"; -import { - formatDecimalForDbRequired, - normalizeDecimalInput, -} from "@/lib/utils/currency"; - -const budgetBaseSchema = z.object({ - categoriaId: uuidSchema("Categoria"), - period: periodSchema, - amount: z - .string({ message: "Informe o valor limite." }) - .trim() - .min(1, "Informe o valor limite.") - .transform((value) => normalizeDecimalInput(value)) - .refine( - (value) => !Number.isNaN(Number.parseFloat(value)), - "Informe um valor limite válido.", - ) - .transform((value) => Number.parseFloat(value)) - .refine( - (value) => value >= 0, - "O valor limite deve ser maior ou igual a zero.", - ), -}); - -const createBudgetSchema = budgetBaseSchema; -const updateBudgetSchema = budgetBaseSchema.extend({ - id: uuidSchema("Orçamento"), -}); -const deleteBudgetSchema = z.object({ - id: uuidSchema("Orçamento"), -}); - -type BudgetCreateInput = z.infer; -type BudgetUpdateInput = z.infer; -type BudgetDeleteInput = z.infer; - -const ensureCategory = async (userId: string, categoriaId: string) => { - const category = await db.query.categorias.findFirst({ - columns: { - id: true, - type: true, - }, - where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), - }); - - if (!category) { - throw new Error("Categoria não encontrada."); - } - - if (category.type !== "despesa") { - throw new Error("Selecione uma categoria de despesa."); - } -}; - -export async function createBudgetAction( - input: BudgetCreateInput, -): Promise { - try { - const user = await getUser(); - const data = createBudgetSchema.parse(input); - - await ensureCategory(user.id, data.categoriaId); - - const duplicateConditions = [ - eq(orcamentos.userId, user.id), - eq(orcamentos.period, data.period), - eq(orcamentos.categoriaId, data.categoriaId), - ] as const; - - const duplicate = await db.query.orcamentos.findFirst({ - columns: { id: true }, - where: and(...duplicateConditions), - }); - - if (duplicate) { - return { - success: false, - error: - "Já existe um orçamento para esta categoria no período selecionado.", - }; - } - - await db.insert(orcamentos).values({ - amount: formatDecimalForDbRequired(data.amount), - period: data.period, - userId: user.id, - categoriaId: data.categoriaId, - }); - - revalidateForEntity("orcamentos"); - - return { success: true, message: "Orçamento criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } -} - -export async function updateBudgetAction( - input: BudgetUpdateInput, -): Promise { - try { - const user = await getUser(); - const data = updateBudgetSchema.parse(input); - - await ensureCategory(user.id, data.categoriaId); - - const duplicateConditions = [ - eq(orcamentos.userId, user.id), - eq(orcamentos.period, data.period), - eq(orcamentos.categoriaId, data.categoriaId), - ne(orcamentos.id, data.id), - ] as const; - - const duplicate = await db.query.orcamentos.findFirst({ - columns: { id: true }, - where: and(...duplicateConditions), - }); - - if (duplicate) { - return { - success: false, - error: - "Já existe um orçamento para esta categoria no período selecionado.", - }; - } - - const [updated] = await db - .update(orcamentos) - .set({ - amount: formatDecimalForDbRequired(data.amount), - period: data.period, - categoriaId: data.categoriaId, - }) - .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) - .returning({ id: orcamentos.id }); - - if (!updated) { - return { - success: false, - error: "Orçamento não encontrado.", - }; - } - - revalidateForEntity("orcamentos"); - - return { success: true, message: "Orçamento atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } -} - -export async function deleteBudgetAction( - input: BudgetDeleteInput, -): Promise { - try { - const user = await getUser(); - const data = deleteBudgetSchema.parse(input); - - const [deleted] = await db - .delete(orcamentos) - .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) - .returning({ id: orcamentos.id }); - - if (!deleted) { - return { - success: false, - error: "Orçamento não encontrado.", - }; - } - - revalidateForEntity("orcamentos"); - - return { success: true, message: "Orçamento removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } -} - -const duplicatePreviousMonthSchema = z.object({ - period: periodSchema, -}); - -type DuplicatePreviousMonthInput = z.infer; - -export async function duplicatePreviousMonthBudgetsAction( - input: DuplicatePreviousMonthInput, -): Promise { - try { - const user = await getUser(); - const data = duplicatePreviousMonthSchema.parse(input); - - // Calcular mês anterior - const [year, month] = data.period.split("-").map(Number); - const currentDate = new Date(year, month - 1, 1); - const previousDate = new Date(currentDate); - previousDate.setMonth(previousDate.getMonth() - 1); - - const prevYear = previousDate.getFullYear(); - const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0"); - const previousPeriod = `${prevYear}-${prevMonth}`; - - // Buscar orçamentos do mês anterior - const previousBudgets = await db.query.orcamentos.findMany({ - where: and( - eq(orcamentos.userId, user.id), - eq(orcamentos.period, previousPeriod), - ), - }); - - if (previousBudgets.length === 0) { - return { - success: false, - error: "Não foram encontrados orçamentos no mês anterior.", - }; - } - - // Buscar orçamentos existentes do mês atual - const currentBudgets = await db.query.orcamentos.findMany({ - where: and( - eq(orcamentos.userId, user.id), - eq(orcamentos.period, data.period), - ), - }); - - // Filtrar para evitar duplicatas - const existingCategoryIds = new Set( - currentBudgets.map((b) => b.categoriaId), - ); - - const budgetsToCopy = previousBudgets.filter( - (b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId), - ); - - if (budgetsToCopy.length === 0) { - return { - success: false, - error: - "Todas as categorias do mês anterior já possuem orçamento neste mês.", - }; - } - - // Inserir novos orçamentos - await db.insert(orcamentos).values( - budgetsToCopy.map((b) => ({ - amount: b.amount, - period: data.period, - userId: user.id, - categoriaId: b.categoriaId as string, - })), - ); - - revalidateForEntity("orcamentos"); - - return { - success: true, - message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } -} diff --git a/app/(dashboard)/pagadores/[pagadorId]/data.ts b/app/(dashboard)/pagadores/[pagadorId]/data.ts deleted file mode 100644 index 4afb298..0000000 --- a/app/(dashboard)/pagadores/[pagadorId]/data.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { and, desc, eq, type SQL } from "drizzle-orm"; -import { - cartoes, - categorias, - compartilhamentosPagador, - contas, - lancamentos, - pagadores, - user as usersTable, -} from "@/db/schema"; -import { db } from "@/lib/db"; - -export type ShareData = { - id: string; - userId: string; - name: string; - email: string; - createdAt: string; -}; - -export async function fetchPagadorShares( - pagadorId: string, -): Promise { - const shareRows = await db - .select({ - id: compartilhamentosPagador.id, - sharedWithUserId: compartilhamentosPagador.sharedWithUserId, - createdAt: compartilhamentosPagador.createdAt, - userName: usersTable.name, - userEmail: usersTable.email, - }) - .from(compartilhamentosPagador) - .innerJoin( - usersTable, - eq(compartilhamentosPagador.sharedWithUserId, usersTable.id), - ) - .where(eq(compartilhamentosPagador.pagadorId, pagadorId)); - - return shareRows.map((share) => ({ - id: share.id, - userId: share.sharedWithUserId, - name: share.userName ?? "Usuário", - email: share.userEmail ?? "email não informado", - createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(), - })); -} - -export async function fetchCurrentUserShare( - pagadorId: string, - userId: string, -): Promise<{ id: string; createdAt: string } | null> { - const shareRow = await db.query.compartilhamentosPagador.findFirst({ - columns: { - id: true, - createdAt: true, - }, - where: and( - eq(compartilhamentosPagador.pagadorId, pagadorId), - eq(compartilhamentosPagador.sharedWithUserId, userId), - ), - }); - - if (!shareRow) { - return null; - } - - return { - id: shareRow.id, - createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(), - }; -} - -export async function fetchPagadorLancamentos(filters: SQL[]) { - const lancamentoRows = await db - .select({ - lancamento: lancamentos, - pagador: pagadores, - conta: contas, - cartao: cartoes, - categoria: categorias, - }) - .from(lancamentos) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .leftJoin(contas, eq(lancamentos.contaId, contas.id)) - .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) - .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where(and(...filters)) - .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); - - // Transformar resultado para o formato esperado - return lancamentoRows.map((row: Record) => ({ - ...row.lancamento, - pagador: row.pagador, - conta: row.conta, - cartao: row.cartao, - categoria: row.categoria, - })); -} diff --git a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx deleted file mode 100644 index b20b108..0000000 --- a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -/** - * Loading state para a página de detalhes do pagador - * Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos) - */ -export default function PagadorDetailsLoading() { - return ( -
- {/* Month Picker placeholder */} -
- - {/* Info do Pagador (sempre visível) */} -
-
- {/* Avatar */} - - -
- {/* Nome + Badge */} -
- - -
- - {/* Email */} - - - {/* Status */} -
- - -
-
- - {/* Botões de ação */} -
- - -
-
-
- - {/* Tabs */} -
-
- - -
- - {/* Conteúdo da aba Visão Geral (grid de cards) */} -
- {/* Card de resumo mensal */} -
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
-
- - {/* Outros cards */} - {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
-
- - - -
-
- ))} -
-
-
- ); -} diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx deleted file mode 100644 index 3f40f53..0000000 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ /dev/null @@ -1,490 +0,0 @@ -import { - RiBankCard2Line, - RiBarcodeLine, - RiWallet3Line, -} from "@remixicon/react"; -import { notFound } from "next/navigation"; -import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; -import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; -import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; -import type { - ContaCartaoFilterOption, - LancamentoFilterOption, - LancamentoItem, - SelectOption, -} from "@/components/lancamentos/types"; -import MonthNavigation from "@/components/month-picker/month-navigation"; -import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card"; -import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card"; -import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card"; -import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card"; -import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card"; -import { - PagadorBoletoCard, - PagadorPaymentStatusCard, -} from "@/components/pagadores/details/pagador-payment-method-cards"; -import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import WidgetCard from "@/components/widget-card"; -import type { pagadores } from "@/db/schema"; -import { getUserId } from "@/lib/auth/server"; -import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - type LancamentoSearchFilters, - mapLancamentosData, - type ResolvedSearchParams, - type SluggedFilters, - type SlugMaps, -} from "@/lib/lancamentos/page-helpers"; -import { getPagadorAccess } from "@/lib/pagadores/access"; -import { - fetchPagadorBoletoItems, - fetchPagadorBoletoStats, - fetchPagadorCardUsage, - fetchPagadorHistory, - fetchPagadorMonthlyBreakdown, - fetchPagadorPaymentStatus, -} from "@/lib/pagadores/details"; -import { parsePeriodParam } from "@/lib/utils/period"; -import { - fetchCurrentUserShare, - fetchPagadorLancamentos, - fetchPagadorShares, -} from "./data"; - -type PageSearchParams = Promise; - -type PageProps = { - params: Promise<{ pagadorId: string }>; - searchParams?: PageSearchParams; -}; - -const capitalize = (value: string) => - value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value; - -const EMPTY_FILTERS: LancamentoSearchFilters = { - transactionFilter: null, - conditionFilter: null, - paymentFilter: null, - pagadorFilter: null, - categoriaFilter: null, - contaCartaoFilter: null, - searchFilter: null, -}; - -const createEmptySlugMaps = (): SlugMaps => ({ - pagador: new Map(), - categoria: new Map(), - conta: new Map(), - cartao: new Map(), -}); - -type OptionSet = ReturnType; - -export default async function Page({ params, searchParams }: PageProps) { - const { pagadorId } = await params; - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - - const access = await getPagadorAccess(userId, pagadorId); - - if (!access) { - notFound(); - } - - const { pagador, canEdit } = access; - const dataOwnerId = pagador.userId; - - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { - period: selectedPeriod, - monthName, - year, - } = parsePeriodParam(periodoParamRaw); - const periodLabel = `${capitalize(monthName)} de ${year}`; - - const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const searchFilters = canEdit - ? allSearchFilters - : { - ...EMPTY_FILTERS, - searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only - }; - - let filterSources: Awaited< - ReturnType - > | null = null; - let loggedUserFilterSources: Awaited< - ReturnType - > | null = null; - let sluggedFilters: SluggedFilters; - let slugMaps: SlugMaps; - - if (canEdit) { - filterSources = await fetchLancamentoFilterSources(dataOwnerId); - sluggedFilters = buildSluggedFilters(filterSources); - slugMaps = buildSlugMaps(sluggedFilters); - } else { - // Buscar opções do usuário logado para usar ao importar - loggedUserFilterSources = await fetchLancamentoFilterSources(userId); - sluggedFilters = { - pagadorFiltersRaw: [], - categoriaFiltersRaw: [], - contaFiltersRaw: [], - cartaoFiltersRaw: [], - }; - slugMaps = createEmptySlugMaps(); - } - - const filters = buildLancamentoWhere({ - userId: dataOwnerId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - pagadorId: pagador.id, - }); - - const sharesPromise = canEdit - ? fetchPagadorShares(pagador.id) - : Promise.resolve([]); - - const currentUserSharePromise = !canEdit - ? fetchCurrentUserShare(pagador.id, userId) - : Promise.resolve(null); - - const [ - lancamentoRows, - monthlyBreakdown, - historyData, - cardUsage, - boletoStats, - boletoItems, - paymentStatus, - shareRows, - currentUserShare, - estabelecimentos, - userPreferences, - ] = await Promise.all([ - fetchPagadorLancamentos(filters), - fetchPagadorMonthlyBreakdown({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorHistory({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorCardUsage({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorBoletoStats({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorBoletoItems({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorPaymentStatus({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - sharesPromise, - currentUserSharePromise, - getRecentEstablishmentsAction(), - fetchUserPreferences(userId), - ]); - - const mappedLancamentos = mapLancamentosData(lancamentoRows); - const lancamentosData = canEdit - ? mappedLancamentos - : mappedLancamentos.map((item) => ({ ...item, readonly: true })); - - const pagadorSharesData = shareRows; - - let optionSets: OptionSet; - let loggedUserOptionSets: OptionSet | null = null; - let effectiveSluggedFilters = sluggedFilters; - - if (canEdit && filterSources) { - optionSets = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); - } else { - effectiveSluggedFilters = { - pagadorFiltersRaw: [ - { - id: pagador.id, - label: pagador.name, - slug: pagador.id, - role: pagador.role, - }, - ], - categoriaFiltersRaw: [], - contaFiltersRaw: [], - cartaoFiltersRaw: [], - }; - optionSets = buildReadOnlyOptionSets(lancamentosData, pagador); - - // Construir opções do usuário logado para usar ao importar - if (loggedUserFilterSources) { - const loggedUserSluggedFilters = buildSluggedFilters( - loggedUserFilterSources, - ); - loggedUserOptionSets = buildOptionSets({ - ...loggedUserSluggedFilters, - pagadorRows: loggedUserFilterSources.pagadorRows, - }); - } - } - - const pagadorSlug = - effectiveSluggedFilters.pagadorFiltersRaw.find( - (item) => item.id === pagador.id, - )?.slug ?? null; - - const pagadorFilterOptions = pagadorSlug - ? optionSets.pagadorFilterOptions.filter( - (option) => option.slug === pagadorSlug, - ) - : optionSets.pagadorFilterOptions; - - const pagadorData = { - id: pagador.id, - name: pagador.name, - email: pagador.email ?? null, - avatarUrl: pagador.avatarUrl ?? null, - status: pagador.status, - note: pagador.note ?? null, - role: pagador.role ?? null, - isAutoSend: pagador.isAutoSend ?? false, - createdAt: pagador.createdAt - ? pagador.createdAt.toISOString() - : new Date().toISOString(), - lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null, - shareCode: canEdit ? pagador.shareCode : null, - canEdit, - }; - - const summaryPreview = { - periodLabel, - totalExpenses: monthlyBreakdown.totalExpenses, - paymentSplits: monthlyBreakdown.paymentSplits, - cardUsage: cardUsage.slice(0, 3).map((item) => ({ - name: item.name, - amount: item.amount, - })), - boletoStats: { - totalAmount: boletoStats.totalAmount, - paidAmount: boletoStats.paidAmount, - pendingAmount: boletoStats.pendingAmount, - paidCount: boletoStats.paidCount, - pendingCount: boletoStats.pendingCount, - }, - lancamentoCount: lancamentosData.length, - }; - - return ( -
- - - - - Perfil - Painel - Lançamentos - - - -
- -
- {canEdit && pagadorData.shareCode ? ( - - ) : null} - {!canEdit && currentUserShare ? ( - - ) : null} -
- - -
- - -
- -
- } - > - - - } - > - - - } - > - - -
-
- - -
- -
-
-
-
- ); -} - -const normalizeOptionLabel = ( - value: string | null | undefined, - fallback: string, -) => (value?.trim().length ? value.trim() : fallback); - -function buildReadOnlyOptionSets( - items: LancamentoItem[], - pagador: typeof pagadores.$inferSelect, -): OptionSet { - const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador"); - const pagadorOptions: SelectOption[] = [ - { - value: pagador.id, - label: pagadorLabel, - slug: pagador.id, - }, - ]; - - const contaOptionsMap = new Map(); - const cartaoOptionsMap = new Map(); - const categoriaOptionsMap = new Map(); - - items.forEach((item) => { - if (item.contaId && !contaOptionsMap.has(item.contaId)) { - contaOptionsMap.set(item.contaId, { - value: item.contaId, - label: normalizeOptionLabel(item.contaName, "Conta sem nome"), - slug: item.contaId, - }); - } - if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) { - cartaoOptionsMap.set(item.cartaoId, { - value: item.cartaoId, - label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"), - slug: item.cartaoId, - }); - } - if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) { - categoriaOptionsMap.set(item.categoriaId, { - value: item.categoriaId, - label: normalizeOptionLabel(item.categoriaName, "Categoria"), - slug: item.categoriaId, - }); - } - }); - - const contaOptions = Array.from(contaOptionsMap.values()); - const cartaoOptions = Array.from(cartaoOptionsMap.values()); - const categoriaOptions = Array.from(categoriaOptionsMap.values()); - - const pagadorFilterOptions: LancamentoFilterOption[] = [ - { slug: pagador.id, label: pagadorLabel }, - ]; - - const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map( - (option) => ({ - slug: option.value, - label: option.label, - }), - ); - - const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [ - ...contaOptions.map((option) => ({ - slug: option.value, - label: option.label, - kind: "conta" as const, - })), - ...cartaoOptions.map((option) => ({ - slug: option.value, - label: option.label, - kind: "cartao" as const, - })), - ]; - - return { - pagadorOptions, - splitPagadorOptions: [], - defaultPagadorId: pagador.id, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - }; -} diff --git a/app/(dashboard)/pagadores/page.tsx b/app/(dashboard)/pagadores/page.tsx deleted file mode 100644 index 67008d3..0000000 --- a/app/(dashboard)/pagadores/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { PagadoresPage } from "@/components/pagadores/pagadores-page"; -import { getUserId } from "@/lib/auth/server"; -import { fetchPagadoresForUser } from "./data"; - -export default async function Page() { - const userId = await getUserId(); - const { pagadores, avatarOptions } = await fetchPagadoresForUser(userId); - - return ( -
- -
- ); -} diff --git a/app/(dashboard)/pre-lancamentos/data.ts b/app/(dashboard)/pre-lancamentos/data.ts deleted file mode 100644 index 1cbb206..0000000 --- a/app/(dashboard)/pre-lancamentos/data.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { and, desc, eq, gte } from "drizzle-orm"; -import type { - InboxItem, - SelectOption, -} from "@/components/pre-lancamentos/types"; -import { - cartoes, - categorias, - contas, - lancamentos, - preLancamentos, -} from "@/db/schema"; -import { db } from "@/lib/db"; -import { - buildOptionSets, - buildSluggedFilters, - fetchLancamentoFilterSources, -} from "@/lib/lancamentos/page-helpers"; - -export async function fetchInboxItems( - userId: string, - status: "pending" | "processed" | "discarded" = "pending", -): Promise { - const items = await db - .select() - .from(preLancamentos) - .where( - and(eq(preLancamentos.userId, userId), eq(preLancamentos.status, status)), - ) - .orderBy(desc(preLancamentos.createdAt)); - - return items; -} - -export async function fetchInboxItemById( - userId: string, - itemId: string, -): Promise { - const [item] = await db - .select() - .from(preLancamentos) - .where( - and(eq(preLancamentos.id, itemId), eq(preLancamentos.userId, userId)), - ) - .limit(1); - - return item ?? null; -} - -export async function fetchCategoriasForSelect( - userId: string, - type?: string, -): Promise { - const query = db - .select({ id: categorias.id, name: categorias.name }) - .from(categorias) - .where( - type - ? and(eq(categorias.userId, userId), eq(categorias.type, type)) - : eq(categorias.userId, userId), - ) - .orderBy(categorias.name); - - return query; -} - -export async function fetchContasForSelect( - userId: string, -): Promise { - const items = await db - .select({ id: contas.id, name: contas.name }) - .from(contas) - .where(and(eq(contas.userId, userId), eq(contas.status, "ativo"))) - .orderBy(contas.name); - - return items; -} - -export async function fetchCartoesForSelect( - userId: string, -): Promise<(SelectOption & { lastDigits?: string })[]> { - const items = await db - .select({ id: cartoes.id, name: cartoes.name }) - .from(cartoes) - .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))) - .orderBy(cartoes.name); - - return items; -} - -export async function fetchAppLogoMap( - userId: string, -): Promise> { - const [userCartoes, userContas] = await Promise.all([ - db - .select({ name: cartoes.name, logo: cartoes.logo }) - .from(cartoes) - .where(eq(cartoes.userId, userId)), - db - .select({ name: contas.name, logo: contas.logo }) - .from(contas) - .where(eq(contas.userId, userId)), - ]); - - const logoMap: Record = {}; - - for (const item of [...userCartoes, ...userContas]) { - if (item.logo) { - logoMap[item.name.toLowerCase()] = item.logo; - } - } - - return logoMap; -} - -export async function fetchPendingInboxCount(userId: string): Promise { - const items = await db - .select({ id: preLancamentos.id }) - .from(preLancamentos) - .where( - and( - eq(preLancamentos.userId, userId), - eq(preLancamentos.status, "pending"), - ), - ); - - return items.length; -} - -/** - * Fetch all data needed for the LancamentoDialog in inbox context - */ -export async function fetchInboxDialogData(userId: string): Promise<{ - pagadorOptions: SelectOption[]; - splitPagadorOptions: SelectOption[]; - defaultPagadorId: string | null; - contaOptions: SelectOption[]; - cartaoOptions: SelectOption[]; - categoriaOptions: SelectOption[]; - estabelecimentos: string[]; -}> { - const filterSources = await fetchLancamentoFilterSources(userId); - const sluggedFilters = buildSluggedFilters(filterSources); - - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); - - // Fetch recent establishments (same approach as getRecentEstablishmentsAction) - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - - const recentEstablishments = await db - .select({ name: lancamentos.name }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - gte(lancamentos.purchaseDate, threeMonthsAgo), - ), - ) - .orderBy(desc(lancamentos.purchaseDate)); - - // Remove duplicates and filter empty names - const filteredNames: string[] = recentEstablishments - .map((r: { name: string }) => r.name) - .filter( - (name: string | null): name is string => - name != null && - name.trim().length > 0 && - !name.toLowerCase().startsWith("pagamento fatura"), - ); - const estabelecimentos = Array.from(new Set(filteredNames)).slice( - 0, - 100, - ); - - return { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - estabelecimentos, - }; -} diff --git a/app/(dashboard)/pre-lancamentos/loading.tsx b/app/(dashboard)/pre-lancamentos/loading.tsx deleted file mode 100644 index f3fdd73..0000000 --- a/app/(dashboard)/pre-lancamentos/loading.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Card } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - -
-
- {Array.from({ length: 6 }).map((_, i) => ( - -
-
- - -
- - -
- - -
-
-
- ))} -
-
-
- ); -} diff --git a/app/(dashboard)/pre-lancamentos/page.tsx b/app/(dashboard)/pre-lancamentos/page.tsx deleted file mode 100644 index 14526a0..0000000 --- a/app/(dashboard)/pre-lancamentos/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { InboxPage } from "@/components/pre-lancamentos/inbox-page"; -import { getUserId } from "@/lib/auth/server"; -import { fetchAppLogoMap, fetchInboxDialogData, fetchInboxItems } from "./data"; - -export default async function Page() { - const userId = await getUserId(); - - const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] = - await Promise.all([ - fetchInboxItems(userId, "pending"), - fetchInboxItems(userId, "processed"), - fetchInboxItems(userId, "discarded"), - fetchInboxDialogData(userId), - fetchAppLogoMap(userId), - ]); - - return ( -
- -
- ); -} diff --git a/app/(dashboard)/relatorios/analise-parcelas/page.tsx b/app/(dashboard)/relatorios/analise-parcelas/page.tsx deleted file mode 100644 index 51306d4..0000000 --- a/app/(dashboard)/relatorios/analise-parcelas/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { InstallmentAnalysisPage } from "@/components/dashboard/installment-analysis/installment-analysis-page"; -import { getUser } from "@/lib/auth/server"; -import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis"; - -export default async function Page() { - const user = await getUser(); - const data = await fetchInstallmentAnalysis(user.id); - - return ( -
- -
- ); -} diff --git a/app/(dashboard)/relatorios/tendencias/data.ts b/app/(dashboard)/relatorios/tendencias/data.ts deleted file mode 100644 index bae4ce2..0000000 --- a/app/(dashboard)/relatorios/tendencias/data.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { asc, eq } from "drizzle-orm"; -import { type Categoria, categorias } from "@/db/schema"; -import { db } from "@/lib/db"; - -export async function fetchUserCategories( - userId: string, -): Promise { - return db.query.categorias.findMany({ - where: eq(categorias.userId, userId), - orderBy: [asc(categorias.name)], - }); -} diff --git a/app/(landing-page)/page.tsx b/app/(landing-page)/page.tsx deleted file mode 100644 index 59439c2..0000000 --- a/app/(landing-page)/page.tsx +++ /dev/null @@ -1,1008 +0,0 @@ -import { - RiArrowRightSLine, - RiBankCard2Line, - RiBarChartBoxLine, - RiCalendarLine, - RiCheckLine, - RiCodeSSlashLine, - RiDatabase2Line, - RiDeviceLine, - RiDownloadCloudLine, - RiEyeOffLine, - RiFileTextLine, - RiFlashlightLine, - RiGitBranchLine, - RiGithubFill, - RiLayoutGridLine, - RiLineChartLine, - RiLockLine, - RiNotification3Line, - RiPercentLine, - RiPieChartLine, - RiRobot2Line, - RiShieldCheckLine, - RiSmartphoneLine, - RiStarLine, - RiTeamLine, - RiTimeLine, - RiWalletLine, -} from "@remixicon/react"; -import { headers } from "next/headers"; -import Image from "next/image"; -import Link from "next/link"; -import { AnimatedThemeToggler } from "@/components/animated-theme-toggler"; -import { AnimateOnScroll } from "@/components/landing/animate-on-scroll"; -import { MobileNav } from "@/components/landing/mobile-nav"; -import { SetupTabs } from "@/components/landing/setup-tabs"; -import { Logo } from "@/components/logo"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { getOptionalUserSession } from "@/lib/auth/server"; - -const mainFeatures = [ - { - icon: RiWalletLine, - title: "Contas e transações", - description: - "Registre suas contas bancárias, cartões e dinheiro. Adicione receitas, despesas e transferências. Organize por categorias. Extratos detalhados por conta.", - }, - { - icon: RiPercentLine, - title: "Parcelamentos avançados", - description: - "Controle completo de compras parceladas. Antecipe parcelas com cálculo automático de desconto. Veja análise consolidada de todas as parcelas em aberto.", - }, - { - icon: RiRobot2Line, - title: "Insights com IA", - description: - "Análises financeiras geradas por IA (Claude, GPT, Gemini). Insights personalizados sobre seus padrões de gastos e recomendações inteligentes.", - }, - { - icon: RiBarChartBoxLine, - title: "Relatórios e gráficos", - description: - "Dashboard com 20+ widgets interativos. Relatórios detalhados por categoria. Gráficos de evolução e comparativos. Exportação em PDF e Excel.", - }, - { - icon: RiBankCard2Line, - title: "Faturas de cartão", - description: - "Cadastre seus cartões e acompanhe as faturas por período. Veja o que ainda não foi fechado. Controle limites, vencimentos e fechamentos.", - }, - { - icon: RiTeamLine, - title: "Gestão colaborativa", - description: - "Compartilhe pagadores com permissões granulares (admin/viewer). Notificações automáticas por e-mail. Colabore em lançamentos compartilhados.", - }, -]; - -const extraFeatures = [ - { - icon: RiPieChartLine, - title: "Categorias e orçamentos", - description: - "Crie categorias personalizadas e defina orçamentos mensais com indicadores visuais.", - }, - { - icon: RiFileTextLine, - title: "Anotações e tarefas", - description: - "Notas de texto e listas de tarefas com checkboxes. Arquivamento para manter histórico.", - }, - { - icon: RiCalendarLine, - title: "Calendário financeiro", - description: - "Visualize transações em calendário mensal. Nunca perca prazos de pagamentos.", - }, - { - icon: RiDownloadCloudLine, - title: "Importação em massa", - description: "Lance múltiplos lançamentos de uma vez", - }, - { - icon: RiEyeOffLine, - title: "Modo privacidade", - description: - "Oculte valores sensíveis com um clique. Tema dark/light. Calculadora integrada.", - }, - { - icon: RiFlashlightLine, - title: "Performance otimizada", - description: "Sistema rápido e com alta performance", - }, -]; - -const screenshotSections = [ - { - title: "Lançamentos", - description: "Registre e organize todas as suas transações financeiras", - lightSrc: "/preview-lancamentos-light.webp", - darkSrc: "/preview-lancamentos-dark.webp", - }, - { - title: "Calendário", - description: "Visualize suas finanças no calendário mensal", - lightSrc: "/preview-calendario-light.webp", - darkSrc: "/preview-calendario-dark.webp", - }, - { - title: "Cartões", - description: "Acompanhe faturas, limites e vencimentos dos seus cartões", - lightSrc: "/preview-cartao-light.webp", - darkSrc: "/preview-cartao-dark.webp", - }, -]; - -const companionBanks = ["Nubank", "Itaú", "Inter", "Mercado Pago", "Outros"]; - -export default async function Page() { - const [session, headersList] = await Promise.all([ - getOptionalUserSession(), - headers(), - ]); - const hostname = headersList.get("host")?.replace(/:\d+$/, ""); - const publicDomain = process.env.PUBLIC_DOMAIN?.replace( - /^https?:\/\//, - "", - ).replace(/:\d+$/, ""); - const isPublicDomain = !!(publicDomain && hostname === publicDomain); - - return ( -
- {/* Navigation */} -
-
- - - {/* Center Navigation Links */} - - - -
-
- - {/* Hero Section */} -
- {/* Background gradient */} -
- -
-
- - - - - Projeto Open Source - - -

- Suas finanças, - do seu jeito -

- -

- Um projeto pessoal de gestão financeira. Self-hosted, sem Open - Finance, sem sincronização automática. Rode no seu computador ou - servidor e tenha controle total sobre suas finanças. -

- -
-

- - Aviso importante: - {" "} - Este sistema requer disciplina. Você precisa registrar - manualmente cada transação. Se prefere algo automático, este - projeto não é pra você. -

-
- -
- - - - - - -
- -
-
- - Seus dados, seu servidor -
-
- - 100% Open Source -
-
-
-
-
- - {/* Metrics Bar */} -
-
-
-
-
-
- -
- 20+ - - Widgets no dashboard - -
-
-
- -
- 100% - - Self-hosted - -
-
-
- -
- +200 - - Stars no GitHub - -
-
-
- -
- +60 - - Forks no GitHub - -
-
-
-
-
- - {/* Dashboard Preview Section */} -
-
-
- -
- openmonetis Dashboard Preview - openmonetis Dashboard Preview -
-
-
-
-
- - {/* Screenshots Gallery Section */} -
-
-
- -
- - Conheça as telas - -

- Veja o que você pode fazer -

-

- Explore as principais telas do OpenMonetis -

-
-
- -
- {screenshotSections.map((section) => ( - -
-

- {section.title} -

-

- {section.description} -

-
-
- {`Preview - {`Preview -
-
- ))} -
-
-
-
- - {/* Features Section */} -
-
-
- -
- - O que tem aqui - -

- Funcionalidades que importam -

-

- Ferramentas simples para organizar suas contas, cartões, - gastos e receitas -

-
-
- - {/* Main Features - larger cards */} - -
- {mainFeatures.map((feature) => ( - - -
-
- -
-
-

- {feature.title} -

-

- {feature.description} -

-
-
-
-
- ))} -
-
- - {/* Extra Features - compact list */} - -
-

- E mais... -

-
- {extraFeatures.map((feature) => ( -
-
- -
-
-

- {feature.title} -

-

- {feature.description} -

-
-
- ))} -
-
-
-
-
-
- - {/* Companion Section */} -
-
-
- -
- {/* Text content */} -
- - - App Android - -

- Capture automaticamente do seu celular -

-

- O OpenMonetis Companion captura notificações de apps - bancários e cria pré-lançamentos automaticamente para você - revisar. -

- - {/* Flow steps */} -
-
-
- -
-
-

- Notificação bancária chega -

-

- O Companion intercepta automaticamente -

-
-
-
-
- -
-
-

- Dados extraídos e enviados -

-

- Valor, descrição e banco são identificados -

-
-
-
-
- -
-
-

- Revise e confirme no OpenMonetis -

-

- Pré-lançamentos ficam na inbox para sua aprovação -

-
-
-
- - {/* Supported banks */} -
-

- Bancos suportados -

-
- {companionBanks.map((bank) => ( - - {bank} - - ))} -
-
- - - - -
- - {/* Companion Screenshot */} -
-
- OpenMonetis Companion App -
-
-
-
-
-
-
- - {/* Tech Stack Section */} -
-
-
- -
- - Stack técnica - -

- Construído com tecnologias modernas -

-

- Open source, self-hosted e fácil de customizar -

-
-
- - -
- - -
- -
-

- Frontend -

-

- Next.js 16, TypeScript, Tailwind CSS, shadcn/ui -

-

- Interface moderna e responsiva com React 19 e App - Router -

-
-
-
-
- - - -
- -
-

- Backend -

-

- PostgreSQL 18, Drizzle ORM, Better Auth -

-

- Banco relacional robusto com type-safe ORM -

-
-
-
-
- - - -
- -
-

- Segurança -

-

- Better Auth com OAuth (Google) e autenticação por - email -

-

- Sessões seguras e proteção de rotas por middleware -

-
-
-
-
- - - -
- -
-

- Deploy -

-

- Docker com multi-stage build, health checks e volumes - persistentes -

-

- Fácil de rodar localmente ou em qualquer servidor -

-
-
-
-
-
-
- -
-

- Seus dados ficam no seu controle. Pode rodar localmente ou no - seu próprio servidor. -

-
-
-
-
- - {/* How to run Section */} -
-
-
- -
- - Como usar - -

- Rode no seu computador -

-

- Não há versão hospedada online. Você precisa rodar localmente. -

-
-
- - - - - -
- - Ver documentação completa → - -
-
-
-
- - {/* Who is this for Section */} -
-
-
- -
-

- Para quem funciona? -

-

- O openmonetis funciona melhor se você: -

-
-
- - -
- - -
-
- -
-
-

- Tem disciplina de registrar gastos -

-

- Não se importa em dedicar alguns minutos por dia ou - semana para manter tudo atualizado -

-
-
-
-
- - - -
-
- -
-
-

- Quer controle total sobre seus dados -

-

- Prefere hospedar seus próprios dados ao invés de - depender de serviços terceiros -

-
-
-
-
- - - -
-
- -
-
-

- Gosta de entender exatamente onde o dinheiro vai -

-

- Quer visualizar padrões de gastos e tomar decisões - informadas -

-
-
-
-
-
-
- - -
-

- Se você não se encaixa nisso, provavelmente vai abandonar - depois de uma semana. E tudo bem! Existem outras ferramentas - com sincronização automática que podem funcionar melhor pra - você. -

-
-
-
-
-
- - {/* CTA Section */} -
-
- -
-

- Pronto para testar? -

-

- Clone o repositório, rode localmente e veja se faz sentido pra - você. É open source e gratuito. -

-
- - - - - - -
-
-
-
-
- - {/* Footer */} -
-
-
-
-
- -

- Projeto pessoal de gestão financeira. Open source e - self-hosted. -

-
- -
-

Projeto

-
    -
  • - - - GitHub - -
  • -
  • - - Documentação - -
  • -
  • - - Reportar Bug - -
  • -
-
- -
-

Companion

-
    -
  • - - - GitHub - -
  • -
  • - - - App Android - -
  • -
-
-
- -
-

- © {new Date().getFullYear()} openmonetis. Projeto open source - sob licença. -

-
- - Seus dados, seu servidor -
-
-
-
-
-
- ); -} diff --git a/app/apple-icon.png b/app/apple-icon.png deleted file mode 100644 index 3eed4df..0000000 Binary files a/app/apple-icon.png and /dev/null differ diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 1ca4fc0..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/icon0.svg b/app/icon0.svg deleted file mode 100644 index 5d0c397..0000000 --- a/app/icon0.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/icon1.png b/app/icon1.png deleted file mode 100644 index 7ffba2d..0000000 Binary files a/app/icon1.png and /dev/null differ diff --git a/biome.json b/biome.json index a4a3a54..964a8c5 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/components.json b/components.json index 186b843..35efd1c 100644 --- a/components.json +++ b/components.json @@ -16,7 +16,7 @@ "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", - "hooks": "@/hooks" + "hooks": "@/lib/hooks" }, "registries": { "@coss": "https://coss.com/ui/r/{name}.json", diff --git a/components/ajustes/delete-account-form.tsx b/components/ajustes/delete-account-form.tsx deleted file mode 100644 index 06f6faa..0000000 --- a/components/ajustes/delete-account-form.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; -import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { authClient } from "@/lib/auth/client"; - -export function DeleteAccountForm() { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [isModalOpen, setIsModalOpen] = useState(false); - const [confirmation, setConfirmation] = useState(""); - - const handleDelete = () => { - startTransition(async () => { - const result = await deleteAccountAction({ - confirmation, - }); - - if (result.success) { - toast.success(result.message); - // Fazer logout e redirecionar para página de login - await authClient.signOut(); - router.push("/"); - } else { - toast.error(result.error); - } - }); - }; - - const handleOpenModal = () => { - setConfirmation(""); - setIsModalOpen(true); - }; - - const handleCloseModal = () => { - if (isPending) return; - setConfirmation(""); - setIsModalOpen(false); - }; - - return ( - <> -
-
-
    -
  • Lançamentos, orçamentos e anotações
  • -
  • Contas, cartões e categorias
  • -
  • Pagadores (incluindo o pagador padrão)
  • -
  • Preferências e configurações
  • -
  • - Resumindo tudo, sua conta será permanentemente removida -
  • -
-
- -
- -
-
- - - { - if (isPending) e.preventDefault(); - }} - onPointerDownOutside={(e) => { - if (isPending) e.preventDefault(); - }} - > - - Você tem certeza? - - Essa ação não pode ser desfeita. Isso irá deletar permanentemente - sua conta e remover seus dados de nossos servidores. - - - -
-
- - setConfirmation(e.target.value)} - disabled={isPending} - placeholder="DELETAR" - autoComplete="off" - /> -
-
- - - - - -
-
- - ); -} diff --git a/components/auth/auth-header.tsx b/components/auth/auth-header.tsx deleted file mode 100644 index 2379229..0000000 --- a/components/auth/auth-header.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { cn } from "@/lib/utils/ui"; - -interface AuthHeaderProps { - title: string; -} - -export function AuthHeader({ title }: AuthHeaderProps) { - return ( -
-

- {title} -

-
- ); -} diff --git a/components/auth/auth-sidebar.tsx b/components/auth/auth-sidebar.tsx deleted file mode 100644 index 6ea4250..0000000 --- a/components/auth/auth-sidebar.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import MagnetLines from "../magnet-lines"; - -function AuthSidebar() { - return ( -
-
- -
- -
-
-

- Controle suas finanças com clareza e foco diário. -

-

- Centralize despesas, organize cartões e acompanhe metas mensais em - um painel inteligente feito para o seu dia a dia. -

-
-
-
- ); -} - -export default AuthSidebar; diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx deleted file mode 100644 index 1efeb98..0000000 --- a/components/auth/signup-form.tsx +++ /dev/null @@ -1,291 +0,0 @@ -"use client"; -import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react"; -import { useRouter } from "next/navigation"; -import { type FormEvent, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, - FieldSeparator, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { authClient, googleSignInAvailable } from "@/lib/auth/client"; -import { cn } from "@/lib/utils/ui"; -import { Logo } from "../logo"; -import { AuthErrorAlert } from "./auth-error-alert"; -import { AuthHeader } from "./auth-header"; -import AuthSidebar from "./auth-sidebar"; -import { GoogleAuthButton } from "./google-auth-button"; - -interface PasswordValidation { - hasLowercase: boolean; - hasUppercase: boolean; - hasNumber: boolean; - hasSpecial: boolean; - hasMinLength: boolean; - hasMaxLength: boolean; - isValid: boolean; -} - -function validatePassword(password: string): PasswordValidation { - const hasLowercase = /[a-z]/.test(password); - const hasUppercase = /[A-Z]/.test(password); - const hasNumber = /\d/.test(password); - const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password); - const hasMinLength = password.length >= 7; - const hasMaxLength = password.length <= 23; - - return { - hasLowercase, - hasUppercase, - hasNumber, - hasSpecial, - hasMinLength, - hasMaxLength, - isValid: - hasLowercase && - hasUppercase && - hasNumber && - hasSpecial && - hasMinLength && - hasMaxLength, - }; -} - -function PasswordRequirement({ met, label }: { met: boolean; label: string }) { - return ( -
- {met ? ( - - ) : ( - - )} - {label} -
- ); -} - -type DivProps = React.ComponentProps<"div">; - -export function SignupForm({ className, ...props }: DivProps) { - const router = useRouter(); - const isGoogleAvailable = googleSignInAvailable; - - const [fullname, setFullname] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - - const [error, setError] = useState(""); - const [loadingEmail, setLoadingEmail] = useState(false); - const [loadingGoogle, setLoadingGoogle] = useState(false); - - const passwordValidation = validatePassword(password); - - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - - if (!passwordValidation.isValid) { - setError("A senha não atende aos requisitos de segurança."); - return; - } - - await authClient.signUp.email( - { - email, - password, - name: fullname, - }, - { - onRequest: () => { - setError(""); - setLoadingEmail(true); - }, - onSuccess: () => { - setLoadingEmail(false); - toast.success("Conta criada com sucesso!"); - router.replace("/dashboard"); - }, - onError: (ctx) => { - setError(ctx.error.message); - setLoadingEmail(false); - }, - }, - ); - } - - async function handleGoogle() { - if (!isGoogleAvailable) { - setError("Login com Google não está disponível no momento."); - return; - } - - // Ativa loading antes de iniciar o fluxo OAuth - setError(""); - setLoadingGoogle(true); - - // OAuth redirect - o loading permanece até a página ser redirecionada - await authClient.signIn.social( - { - provider: "google", - callbackURL: "/dashboard", - }, - { - onError: (ctx) => { - // Só desativa loading se houver erro - setError(ctx.error.message); - setLoadingGoogle(false); - }, - }, - ); - } - - return ( -
- - - -
- - - - - - - Nome completo - setFullname(e.target.value)} - aria-invalid={!!error} - /> - - - - E-mail - setEmail(e.target.value)} - aria-invalid={!!error} - /> - - - - Senha - setPassword(e.target.value)} - aria-invalid={ - !!error || - (password.length > 0 && !passwordValidation.isValid) - } - maxLength={23} - /> - {password.length > 0 && ( -
- - - - - - -
- )} -
- - - - - - - Ou continue com - - - - - - - - Já tem uma conta?{" "} - - Entrar - - -
-
- - -
-
- - - - Voltar para o site - - -
- ); -} diff --git a/components/categorias/category-form-fields.tsx b/components/categorias/category-form-fields.tsx deleted file mode 100644 index 2d1e417..0000000 --- a/components/categorias/category-form-fields.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { RiMoreLine } from "@remixicon/react"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - CATEGORY_TYPE_LABEL, - CATEGORY_TYPES, -} from "@/lib/categorias/constants"; -import { getCategoryIconOptions } from "@/lib/categorias/icons"; -import { cn } from "@/lib/utils/ui"; - -import { CategoryIcon } from "./category-icon"; -import { TypeSelectContent } from "./category-select-items"; -import type { CategoryFormValues } from "./types"; - -interface CategoryFormFieldsProps { - values: CategoryFormValues; - onChange: (field: keyof CategoryFormValues, value: string) => void; -} - -export function CategoryFormFields({ - values, - onChange, -}: CategoryFormFieldsProps) { - const [popoverOpen, setPopoverOpen] = useState(false); - const iconOptions = getCategoryIconOptions(); - - const handleIconSelect = (icon: string) => { - onChange("icon", icon); - setPopoverOpen(false); - }; - - return ( -
-
- - onChange("name", event.target.value)} - placeholder="Ex.: Alimentação" - required - /> -
- -
- - -
- -
- -
-
- {values.icon ? ( - - ) : ( - - )} -
- - - - - -
- {iconOptions.map((option) => ( - - ))} -
-
-
-
-

- Escolha um ícone que represente melhor esta categoria. -

-
-
- ); -} diff --git a/components/categorias/category-icon.tsx b/components/categorias/category-icon.tsx deleted file mode 100644 index 323d60b..0000000 --- a/components/categorias/category-icon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import type { RemixiconComponentType } from "@remixicon/react"; -import * as RemixIcons from "@remixicon/react"; -import { cn } from "@/lib/utils/ui"; - -const ICONS = RemixIcons as Record; -const FALLBACK_ICON = ICONS.RiPriceTag3Line; - -interface CategoryIconProps { - name?: string | null; - className?: string; -} - -export function CategoryIcon({ name, className }: CategoryIconProps) { - const IconComponent = - (name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null; - - if (!IconComponent) { - return ( - - {name ?? "Categoria"} - - ); - } - - return ; -} diff --git a/components/contas/account-statement-card.tsx b/components/contas/account-statement-card.tsx deleted file mode 100644 index 496884b..0000000 --- a/components/contas/account-statement-card.tsx +++ /dev/null @@ -1,217 +0,0 @@ -"use client"; -import { RiInformationLine } from "@remixicon/react"; -import Image from "next/image"; -import { type ReactNode, useMemo } from "react"; -import MoneyValues from "@/components/money-values"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils/ui"; - -type DetailValue = string | number | ReactNode; - -type AccountStatementCardProps = { - accountName: string; - accountType: string; - status: string; - periodLabel: string; - currentBalance: number; - openingBalance: number; - totalIncomes: number; - totalExpenses: number; - logo?: string | null; - actions?: React.ReactNode; -}; - -const resolveLogoPath = (logo?: string | null) => { - if (!logo) return null; - if ( - logo.startsWith("http://") || - logo.startsWith("https://") || - logo.startsWith("data:") - ) { - return logo; - } - - return logo.startsWith("/") ? logo : `/logos/${logo}`; -}; - -const getAccountStatusBadgeVariant = ( - status: string, -): "success" | "secondary" => { - const normalizedStatus = status.toLowerCase(); - if (normalizedStatus === "ativa") { - return "success"; - } - return "outline"; -}; - -export function AccountStatementCard({ - accountName, - accountType, - status, - periodLabel, - currentBalance, - openingBalance, - totalIncomes, - totalExpenses, - logo, - actions, -}: AccountStatementCardProps) { - const logoPath = useMemo(() => resolveLogoPath(logo), [logo]); - - const formatCurrency = (value: number) => - value.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - }); - - return ( - - -
- {logoPath ? ( -
- {`Logo -
- ) : null} - -
-
- - {accountName} - -

- Extrato de {periodLabel} -

-
- {actions ?
{actions}
: null} -
-
-
- - - {/* Composição do Saldo */} -
- } - tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês." - /> - -
- - {formatCurrency(totalIncomes)} - - } - tooltip="Total de receitas deste mês classificadas como pagas para esta conta." - /> - - {formatCurrency(totalExpenses)} - - } - tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)." - /> - - = 0 - ? "text-success" - : "text-destructive", - )} - /> - } - tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente." - /> -
- - {/* Saldo Atual - Destaque Principal */} - } - tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês." - /> -
- - {/* Informações da Conta */} -
- - - - {status} - -
- } - tooltip="Indica se a conta está ativa para lançamentos ou foi desativada." - /> -
- - - ); -} - -function DetailItem({ - label, - value, - className, - tooltip, -}: { - label: string; - value: DetailValue; - className?: string; - tooltip?: string; -}) { - return ( -
- - {label} - {tooltip ? ( - - - - - - {tooltip} - - - ) : null} - -
{value}
-
- ); -} diff --git a/components/dashboard/boletos-widget.tsx b/components/dashboard/boletos-widget.tsx deleted file mode 100644 index 09242e6..0000000 --- a/components/dashboard/boletos-widget.tsx +++ /dev/null @@ -1,388 +0,0 @@ -"use client"; - -import { - RiBarcodeFill, - RiCheckboxCircleFill, - RiCheckboxCircleLine, - RiLoader4Line, - RiMoneyDollarCircleLine, -} from "@remixicon/react"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useTransition } from "react"; -import { toast } from "sonner"; -import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions"; -import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; -import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter as ModalFooter, -} from "@/components/ui/dialog"; -import type { DashboardBoleto } from "@/lib/dashboard/boletos"; -import { cn } from "@/lib/utils/ui"; -import { Badge } from "../ui/badge"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type BoletosWidgetProps = { - boletos: DashboardBoleto[]; -}; - -type ModalState = "idle" | "processing" | "success"; - -const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - timeZone: "UTC", -}); - -const buildDateLabel = (value: string | null, prefix?: string) => { - if (!value) { - return null; - } - - const [year, month, day] = value.split("-").map((part) => Number(part)); - if (!year || !month || !day) { - return null; - } - - const formatted = DATE_FORMATTER.format( - new Date(Date.UTC(year, month - 1, day)), - ); - - return prefix ? `${prefix} ${formatted}` : formatted; -}; - -const buildStatusLabel = (boleto: DashboardBoleto) => { - if (boleto.isSettled) { - return buildDateLabel(boleto.boletoPaymentDate, "Pago em"); - } - - return buildDateLabel(boleto.dueDate, "Vence em"); -}; - -const getTodayDateString = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -export function BoletosWidget({ boletos }: BoletosWidgetProps) { - const router = useRouter(); - const [items, setItems] = useState(boletos); - const [selectedId, setSelectedId] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalState, setModalState] = useState("idle"); - const [isPending, startTransition] = useTransition(); - - useEffect(() => { - setItems(boletos); - }, [boletos]); - - const selectedBoleto = useMemo( - () => items.find((boleto) => boleto.id === selectedId) ?? null, - [items, selectedId], - ); - - const isProcessing = modalState === "processing" || isPending; - - const selectedBoletoDueLabel = selectedBoleto - ? buildDateLabel(selectedBoleto.dueDate, "Vencimento:") - : null; - - const handleOpenModal = (boletoId: string) => { - setSelectedId(boletoId); - setModalState("idle"); - setIsModalOpen(true); - }; - - const resetModalState = () => { - setIsModalOpen(false); - setSelectedId(null); - setModalState("idle"); - }; - - const handleConfirmPayment = () => { - if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) { - return; - } - - setModalState("processing"); - - startTransition(async () => { - const result = await toggleLancamentoSettlementAction({ - id: selectedBoleto.id, - value: true, - }); - - if (!result.success) { - toast.error(result.error); - setModalState("idle"); - return; - } - - setItems((previous) => - previous.map((boleto) => - boleto.id === selectedBoleto.id - ? { - ...boleto, - isSettled: true, - boletoPaymentDate: getTodayDateString(), - } - : boleto, - ), - ); - toast.success(result.message); - router.refresh(); - setModalState("success"); - }); - }; - - const getStatusBadgeVariant = (status: string): "success" | "info" => { - const normalizedStatus = status.toLowerCase(); - if (normalizedStatus === "pendente") { - return "info"; - } - return "success"; - }; - - return ( - <> - - {items.length === 0 ? ( - } - title="Nenhum boleto cadastrado para o período selecionado" - description="Cadastre boletos para monitorar os pagamentos aqui." - /> - ) : ( -
    - {items.map((boleto) => { - const statusLabel = buildStatusLabel(boleto); - const isOverdue = (() => { - if (boleto.isSettled || !boleto.dueDate) return false; - const [y, m, d] = boleto.dueDate.split("-").map(Number); - if (!y || !m || !d) return false; - return new Date(Date.UTC(y, m - 1, d)) < new Date(); - })(); - - return ( -
  • -
    - - -
    - - {boleto.name} - -
    - - {statusLabel} - -
    -
    -
    - -
    - - -
    -
  • - ); - })} -
- )} -
- - { - if (!open) { - if (isProcessing) { - return; - } - resetModalState(); - return; - } - setIsModalOpen(true); - }} - > - { - if (isProcessing) { - event.preventDefault(); - return; - } - resetModalState(); - }} - onPointerDownOutside={(event) => { - if (isProcessing) { - event.preventDefault(); - } - }} - > - {modalState === "success" ? ( -
-
- -
-
- - Pagamento registrado! - - - Atualizamos o status do boleto para pago. Em instantes ele - aparecerá como baixado no histórico. - -
- - - -
- ) : ( - <> - - Confirmar pagamento do boleto - - Confirme os dados para registrar o pagamento. Você poderá - editar o lançamento depois, se necessário. - - - - {selectedBoleto ? ( -
-
-
-
-
- -
-
-

- Boleto -

-

- {selectedBoleto.name} -

-
-
- {selectedBoletoDueLabel ? ( -
-

- {selectedBoletoDueLabel} -

-
- ) : null} -
-
- -
-
-
- - - Valor do Boleto - -
- -
-
-
- - - Status - -
- - {selectedBoleto.isSettled ? "Pago" : "Pendente"} - -
-
-
- ) : null} - - - - - - - )} -
-
- - ); -} diff --git a/components/dashboard/category-history-widget.tsx b/components/dashboard/category-history-widget.tsx deleted file mode 100644 index ac91f6a..0000000 --- a/components/dashboard/category-history-widget.tsx +++ /dev/null @@ -1,467 +0,0 @@ -"use client"; -import { - RiArrowDownSLine, - RiBarChartBoxLine, - RiCloseLine, -} from "@remixicon/react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, -} from "@/components/ui/chart"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; -import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history"; -import { CATEGORY_COLORS } from "@/lib/utils/category-colors"; -import { getIconComponent } from "@/lib/utils/icons"; - -type CategoryHistoryWidgetProps = { - data: CategoryHistoryData; -}; - -const STORAGE_KEY_SELECTED = "dashboard-category-history-selected"; - -const CHART_COLORS = CATEGORY_COLORS; - -export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) { - const [selectedCategories, setSelectedCategories] = useState([]); - const [isClient, setIsClient] = useState(false); - const [open, setOpen] = useState(false); - const isFirstRender = useRef(true); - - // Load from sessionStorage on mount and save on changes - useEffect(() => { - setIsClient(true); - - // Only load from storage on first render - if (isFirstRender.current) { - const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED); - if (stored) { - try { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - const validCategories = parsed.filter((id) => - data.allCategories.some((cat) => cat.id === id), - ); - setSelectedCategories(validCategories.slice(0, 5)); - } - } catch (_e) { - // Invalid JSON, ignore - } - } - isFirstRender.current = false; - } else { - // Save to storage on subsequent changes - sessionStorage.setItem( - STORAGE_KEY_SELECTED, - JSON.stringify(selectedCategories), - ); - } - }, [selectedCategories, data.allCategories]); - - // Filter data to show only selected categories with vibrant colors - const filteredCategories = useMemo(() => { - return selectedCategories - .map((id, index) => { - const cat = data.categories.find((c) => c.id === id); - if (!cat) return null; - return { - ...cat, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - }) - .filter(Boolean) as Array<{ - id: string; - name: string; - icon: string | null; - color: string; - data: Record; - }>; - }, [data.categories, selectedCategories]); - - // Filter chart data to include only selected categories - const filteredChartData = useMemo(() => { - if (filteredCategories.length === 0) { - return data.chartData.map((item) => ({ month: item.month })); - } - - return data.chartData.map((item) => { - const filtered: Record = { month: item.month }; - filteredCategories.forEach((category) => { - filtered[category.name] = item[category.name] || 0; - }); - return filtered; - }); - }, [data.chartData, filteredCategories]); - - // Build chart config dynamically from filtered categories - const chartConfig = useMemo(() => { - const config: ChartConfig = {}; - - filteredCategories.forEach((category) => { - config[category.name] = { - label: category.name, - color: category.color, - }; - }); - - return config; - }, [filteredCategories]); - - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); - }; - - const formatCurrencyCompact = (value: number) => { - if (value >= 1000) { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - notation: "compact", - }).format(value); - } - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); - }; - - const handleAddCategory = (categoryId: string) => { - if ( - categoryId && - !selectedCategories.includes(categoryId) && - selectedCategories.length < 5 - ) { - setSelectedCategories([...selectedCategories, categoryId]); - setOpen(false); - } - }; - - const handleRemoveCategory = (categoryId: string) => { - setSelectedCategories(selectedCategories.filter((id) => id !== categoryId)); - }; - - const handleClearAll = () => { - setSelectedCategories([]); - }; - - const availableCategories = useMemo(() => { - return data.allCategories.filter( - (cat) => !selectedCategories.includes(cat.id), - ); - }, [data.allCategories, selectedCategories]); - - const selectedCategoryDetails = useMemo(() => { - return selectedCategories - .map((id) => data.allCategories.find((cat) => cat.id === id)) - .filter(Boolean); - }, [selectedCategories, data.allCategories]); - - const isEmpty = filteredCategories.length === 0; - - // Group available categories by type - const { despesaCategories, receitaCategories } = useMemo(() => { - const despesa = availableCategories.filter((cat) => cat.type === "despesa"); - const receita = availableCategories.filter((cat) => cat.type === "receita"); - return { despesaCategories: despesa, receitaCategories: receita }; - }, [availableCategories]); - - if (!isClient) { - return null; - } - - return ( - - -
- {selectedCategoryDetails.length > 0 && ( -
-
- {selectedCategoryDetails.map((category) => { - if (!category) return null; - const IconComponent = category.icon - ? getIconComponent(category.icon) - : null; - const colorIndex = selectedCategories.indexOf(category.id); - const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; - - return ( -
- {IconComponent ? ( - - ) : ( -
- )} - {category.name} - -
- ); - })} -
-
- - {selectedCategories.length}/5 selecionadas - - -
-
- )} - - {selectedCategories.length < 5 && availableCategories.length > 0 && ( - - - - - - - - - Nenhuma categoria encontrada. - - {despesaCategories.length > 0 && ( - - {despesaCategories.map((category) => { - const IconComponent = category.icon - ? getIconComponent(category.icon) - : null; - return ( - handleAddCategory(category.id)} - className="gap-2" - > - {IconComponent ? ( - - ) : ( -
- )} - {category.name} - - ); - })} - - )} - - {receitaCategories.length > 0 && ( - - {receitaCategories.map((category) => { - const IconComponent = category.icon - ? getIconComponent(category.icon) - : null; - return ( - handleAddCategory(category.id)} - className="gap-2" - > - {IconComponent ? ( - - ) : ( -
- )} - {category.name} - - ); - })} - - )} - - - - - )} -
- - {isEmpty ? ( -
- - } - title="Selecione categorias para visualizar" - description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês." - /> -
- ) : ( - - - - {filteredCategories.map((category) => ( - - - - - ))} - - - - - { - if (!active || !payload || payload.length === 0) { - return null; - } - - // Sort payload by value (descending) - const sortedPayload = [...payload].sort( - (a, b) => (b.value as number) - (a.value as number), - ); - - return ( -
-
- {payload[0].payload.month} -
-
- {sortedPayload - .filter((entry) => (entry.value as number) > 0) - .map((entry) => { - const config = - chartConfig[ - entry.dataKey as keyof typeof chartConfig - ]; - const value = entry.value as number; - - return ( -
-
-
- - {config?.label} - -
- - {formatCurrency(value)} - -
- ); - })} -
-
- ); - }} - cursor={{ - stroke: "hsl(var(--muted-foreground))", - strokeWidth: 1, - }} - /> - {filteredCategories.map((category) => ( - - ))} - - - )} - - - ); -} diff --git a/components/dashboard/dashboard-welcome.tsx b/components/dashboard/dashboard-welcome.tsx deleted file mode 100644 index 95395ae..0000000 --- a/components/dashboard/dashboard-welcome.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import MagnetLines from "../magnet-lines"; -import { Card } from "../ui/card"; - -type DashboardWelcomeProps = { - name?: string | null; - disableMagnetlines?: boolean; -}; - -const capitalizeFirstLetter = (value: string) => - value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value; - -const formatCurrentDate = (date = new Date()) => { - const formatted = new Intl.DateTimeFormat("pt-BR", { - weekday: "long", - day: "numeric", - month: "long", - year: "numeric", - hour12: false, - timeZone: "America/Sao_Paulo", - }).format(date); - - return capitalizeFirstLetter(formatted); -}; - -const getGreeting = () => { - const now = new Date(); - - // Get hour in Brasilia timezone - const brasiliaHour = new Intl.DateTimeFormat("pt-BR", { - hour: "numeric", - hour12: false, - timeZone: "America/Sao_Paulo", - }).format(now); - - const hour = parseInt(brasiliaHour, 10); - - if (hour >= 5 && hour < 12) { - return "Bom dia"; - } else if (hour >= 12 && hour < 18) { - return "Boa tarde"; - } else { - return "Boa noite"; - } -}; - -export function DashboardWelcome({ - name, - disableMagnetlines = false, -}: DashboardWelcomeProps) { - const displayName = name && name.trim().length > 0 ? name : "Administrador"; - const formattedDate = formatCurrentDate(); - const greeting = getGreeting(); - - return ( - -
- -
-
-

- {greeting}, {displayName}! -

-

{formattedDate}

-
-
- ); -} diff --git a/components/dashboard/expenses-by-category-widget-with-chart.tsx b/components/dashboard/expenses-by-category-widget-with-chart.tsx deleted file mode 100644 index 669a825..0000000 --- a/components/dashboard/expenses-by-category-widget-with-chart.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client"; - -import { - RiArrowDownSFill, - RiArrowUpSFill, - RiExternalLinkLine, - RiListUnordered, - RiPieChart2Line, - RiPieChartLine, - RiWallet3Line, -} from "@remixicon/react"; -import Link from "next/link"; -import { useMemo, useState } from "react"; -import { Pie, PieChart, Tooltip } from "recharts"; -import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; -import MoneyValues from "@/components/money-values"; -import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; -import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category"; -import { formatPeriodForUrl } from "@/lib/utils/period"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type ExpensesByCategoryWidgetWithChartProps = { - data: ExpensesByCategoryData; - period: string; -}; - -const formatPercentage = (value: number) => { - return `${Math.abs(value).toFixed(0)}%`; -}; - -const formatCurrency = (value: number) => - new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(value); - -export function ExpensesByCategoryWidgetWithChart({ - data, - period, -}: ExpensesByCategoryWidgetWithChartProps) { - const [activeTab, setActiveTab] = useState<"list" | "chart">("list"); - const periodParam = formatPeriodForUrl(period); - - // Configuração do chart com cores do CSS - const chartConfig = useMemo(() => { - const config: ChartConfig = {}; - const colors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", - "var(--chart-1)", - "var(--chart-2)", - ]; - - if (data.categories.length <= 7) { - data.categories.forEach((category, index) => { - config[category.categoryId] = { - label: category.categoryName, - color: colors[index % colors.length], - }; - }); - } else { - // Top 7 + Outros - const top7 = data.categories.slice(0, 7); - top7.forEach((category, index) => { - config[category.categoryId] = { - label: category.categoryName, - color: colors[index % colors.length], - }; - }); - config.outros = { - label: "Outros", - color: "var(--chart-6)", - }; - } - - return config; - }, [data.categories]); - - // Preparar dados para o gráfico de pizza - Top 7 + Outros - const chartData = useMemo(() => { - if (data.categories.length <= 7) { - return data.categories.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - } - - // Pegar top 7 categorias - const top7 = data.categories.slice(0, 7); - const others = data.categories.slice(7); - - // Somar o restante - const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0); - const othersPercentage = others.reduce( - (sum, cat) => sum + cat.percentageOfTotal, - 0, - ); - - const top7Data = top7.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - - // Adicionar "Outros" se houver - if (others.length > 0) { - top7Data.push({ - category: "outros", - name: "Outros", - value: othersTotal, - percentage: othersPercentage, - fill: chartConfig.outros?.color, - }); - } - - return top7Data; - }, [data.categories, chartConfig]); - - if (data.categories.length === 0) { - return ( - } - title="Nenhuma despesa encontrada" - description="Quando houver despesas registradas, elas aparecerão aqui." - /> - ); - } - - return ( - setActiveTab(v as "list" | "chart")} - className="w-full" - > -
- - - - Lista - - - - Gráfico - - -
- - -
- {data.categories.map((category, index) => { - const hasIncrease = - category.percentageChange !== null && - category.percentageChange > 0; - const hasDecrease = - category.percentageChange !== null && - category.percentageChange < 0; - const hasBudget = category.budgetAmount !== null; - const budgetExceeded = - hasBudget && - category.budgetUsedPercentage !== null && - category.budgetUsedPercentage > 100; - - const exceededAmount = - budgetExceeded && category.budgetAmount - ? category.currentAmount - category.budgetAmount - : 0; - - return ( -
-
-
- - -
-
- - - {category.categoryName} - - - -
-
- - {formatPercentage(category.percentageOfTotal)} da - despesa total - -
-
-
- -
- - {category.percentageChange !== null && ( - - {hasIncrease && } - {hasDecrease && } - {formatPercentage(category.percentageChange)} - - )} -
-
- - {hasBudget && category.budgetUsedPercentage !== null && ( -
- - - {budgetExceeded ? ( - <> - {formatPercentage(category.budgetUsedPercentage)} do - limite - excedeu em {formatCurrency(exceededAmount)} - - ) : ( - <> - {formatPercentage(category.budgetUsedPercentage)} do - limite - - )} - -
- )} -
- ); - })} -
-
- - -
- - - formatPercentage(entry.percentage)} - outerRadius={75} - dataKey="value" - nameKey="category" - /> - { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-
-
- - {data.name} - - - {formatCurrency(data.value)} - - - {formatPercentage(data.percentage)} do total - -
-
-
- ); - } - return null; - }} - /> -
-
- -
- {chartData.map((entry, index) => ( -
-
- - {entry.name} - -
- ))} -
-
- - - ); -} diff --git a/components/dashboard/goals-progress-widget.tsx b/components/dashboard/goals-progress-widget.tsx deleted file mode 100644 index b6e56dd..0000000 --- a/components/dashboard/goals-progress-widget.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { RiFundsLine, RiPencilLine } from "@remixicon/react"; -import { useCallback, useMemo, useState } from "react"; -import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; -import MoneyValues from "@/components/money-values"; -import { BudgetDialog } from "@/components/orcamentos/budget-dialog"; -import type { Budget, BudgetCategory } from "@/components/orcamentos/types"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; -import type { GoalsProgressData } from "@/lib/dashboard/goals-progress"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type GoalsProgressWidgetProps = { - data: GoalsProgressData; -}; - -const clamp = (value: number, min: number, max: number) => - Math.min(max, Math.max(min, value)); - -const formatPercentage = (value: number, withSign = false) => - `${new Intl.NumberFormat("pt-BR", { - minimumFractionDigits: 0, - maximumFractionDigits: 1, - ...(withSign ? { signDisplay: "always" as const } : {}), - }).format(value)}%`; - -export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) { - const [editOpen, setEditOpen] = useState(false); - const [selectedBudget, setSelectedBudget] = useState(null); - - const categories = useMemo( - () => - data.categories.map((category) => ({ - id: category.id, - name: category.name, - icon: category.icon, - })), - [data.categories], - ); - - const defaultPeriod = data.items[0]?.period ?? ""; - - const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => { - setSelectedBudget({ - id: item.id, - amount: item.budgetAmount, - spent: item.spentAmount, - period: item.period, - createdAt: item.createdAt, - category: item.categoryId - ? { - id: item.categoryId, - name: item.categoryName, - icon: item.categoryIcon, - } - : null, - }); - setEditOpen(true); - }, []); - - const handleEditOpenChange = useCallback((open: boolean) => { - setEditOpen(open); - if (!open) { - setSelectedBudget(null); - } - }, []); - - if (data.items.length === 0) { - return ( - } - title="Nenhum orçamento para o período" - description="Cadastre orçamentos para acompanhar o progresso das metas." - /> - ); - } - - return ( -
-
    - {data.items.map((item, index) => { - const statusColor = - item.status === "exceeded" ? "text-destructive" : ""; - const progressValue = clamp(item.usedPercentage, 0, 100); - const percentageDelta = item.usedPercentage - 100; - - return ( -
  • -
    -
    - -
    -

    - {item.categoryName} -

    -

    - de{" "} - -

    -
    -
    - -
    - - {formatPercentage(percentageDelta, true)} - - -
    -
    -
    - -
    -
  • - ); - })} -
- - -
- ); -} diff --git a/components/dashboard/income-by-category-widget-with-chart.tsx b/components/dashboard/income-by-category-widget-with-chart.tsx deleted file mode 100644 index 2411018..0000000 --- a/components/dashboard/income-by-category-widget-with-chart.tsx +++ /dev/null @@ -1,331 +0,0 @@ -"use client"; - -import { - RiArrowDownSFill, - RiArrowUpSFill, - RiExternalLinkLine, - RiListUnordered, - RiPieChart2Line, - RiPieChartLine, - RiWallet3Line, -} from "@remixicon/react"; -import Link from "next/link"; -import { useMemo, useState } from "react"; -import { Pie, PieChart, Tooltip } from "recharts"; -import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; -import MoneyValues from "@/components/money-values"; -import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; -import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category"; -import { formatPeriodForUrl } from "@/lib/utils/period"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type IncomeByCategoryWidgetWithChartProps = { - data: IncomeByCategoryData; - period: string; -}; - -const formatPercentage = (value: number) => { - return `${Math.abs(value).toFixed(1)}%`; -}; - -const formatCurrency = (value: number) => - new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(value); - -export function IncomeByCategoryWidgetWithChart({ - data, - period, -}: IncomeByCategoryWidgetWithChartProps) { - const [activeTab, setActiveTab] = useState<"list" | "chart">("list"); - const periodParam = formatPeriodForUrl(period); - - // Configuração do chart com cores do CSS - const chartConfig = useMemo(() => { - const config: ChartConfig = {}; - const colors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", - "var(--chart-1)", - "var(--chart-2)", - ]; - - if (data.categories.length <= 7) { - data.categories.forEach((category, index) => { - config[category.categoryId] = { - label: category.categoryName, - color: colors[index % colors.length], - }; - }); - } else { - // Top 7 + Outros - const top7 = data.categories.slice(0, 7); - top7.forEach((category, index) => { - config[category.categoryId] = { - label: category.categoryName, - color: colors[index % colors.length], - }; - }); - config.outros = { - label: "Outros", - color: "var(--chart-6)", - }; - } - - return config; - }, [data.categories]); - - // Preparar dados para o gráfico de pizza - Top 7 + Outros - const chartData = useMemo(() => { - if (data.categories.length <= 7) { - return data.categories.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - } - - // Pegar top 7 categorias - const top7 = data.categories.slice(0, 7); - const others = data.categories.slice(7); - - // Somar o restante - const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0); - const othersPercentage = others.reduce( - (sum, cat) => sum + cat.percentageOfTotal, - 0, - ); - - const top7Data = top7.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - - // Adicionar "Outros" se houver - if (others.length > 0) { - top7Data.push({ - category: "outros", - name: "Outros", - value: othersTotal, - percentage: othersPercentage, - fill: chartConfig.outros?.color, - }); - } - - return top7Data; - }, [data.categories, chartConfig]); - - if (data.categories.length === 0) { - return ( - } - title="Nenhuma receita encontrada" - description="Quando houver receitas registradas, elas aparecerão aqui." - /> - ); - } - - return ( - setActiveTab(v as "list" | "chart")} - className="w-full" - > -
- - - - Lista - - - - Gráfico - - -
- - -
- {data.categories.map((category, index) => { - const hasIncrease = - category.percentageChange !== null && - category.percentageChange > 0; - const hasDecrease = - category.percentageChange !== null && - category.percentageChange < 0; - const hasBudget = category.budgetAmount !== null; - const budgetExceeded = - hasBudget && - category.budgetUsedPercentage !== null && - category.budgetUsedPercentage > 100; - - const exceededAmount = - budgetExceeded && category.budgetAmount - ? category.currentAmount - category.budgetAmount - : 0; - - return ( -
-
-
- - -
-
- - - {category.categoryName} - - - -
-
- - {formatPercentage(category.percentageOfTotal)} da - receita total - -
-
-
- -
- - {category.percentageChange !== null && ( - - {hasIncrease && } - {hasDecrease && } - {formatPercentage(category.percentageChange)} - - )} -
-
- - {hasBudget && - category.budgetUsedPercentage !== null && - category.budgetAmount !== null && ( -
- - - {budgetExceeded ? ( - <> - {formatPercentage(category.budgetUsedPercentage)} do - limite {formatCurrency(category.budgetAmount)} - - excedeu em {formatCurrency(exceededAmount)} - - ) : ( - <> - {formatPercentage(category.budgetUsedPercentage)} do - limite {formatCurrency(category.budgetAmount)} - - )} - -
- )} -
- ); - })} -
-
- - -
- - - formatPercentage(entry.percentage)} - outerRadius={75} - dataKey="value" - nameKey="category" - /> - { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-
-
- - {data.name} - - - {formatCurrency(data.value)} - - - {formatPercentage(data.percentage)} do total - -
-
-
- ); - } - return null; - }} - /> -
-
- -
- {chartData.map((entry, index) => ( -
-
- - {entry.name} - -
- ))} -
-
- - - ); -} diff --git a/components/dashboard/installment-expenses-widget.tsx b/components/dashboard/installment-expenses-widget.tsx deleted file mode 100644 index fc46ea9..0000000 --- a/components/dashboard/installment-expenses-widget.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { RiNumbersLine } from "@remixicon/react"; -import Image from "next/image"; -import MoneyValues from "@/components/money-values"; -import { CardContent } from "@/components/ui/card"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses"; -import { - calculateLastInstallmentDate, - formatLastInstallmentDate, -} from "@/lib/installments/utils"; -import { Progress } from "../ui/progress"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type InstallmentExpensesWidgetProps = { - data: InstallmentExpensesData; -}; - -const buildCompactInstallmentLabel = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (currentInstallment && installmentCount) { - return `${currentInstallment} de ${installmentCount}`; - } - return null; -}; - -const isLastInstallment = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount) return false; - return currentInstallment === installmentCount && installmentCount > 1; -}; - -const calculateRemainingInstallments = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount) return 0; - return Math.max(0, installmentCount - currentInstallment); -}; - -const calculateRemainingAmount = ( - amount: number, - currentInstallment: number | null, - installmentCount: number | null, -) => { - const remaining = calculateRemainingInstallments( - currentInstallment, - installmentCount, - ); - return amount * remaining; -}; - -const formatEndDate = ( - period: string, - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount) return null; - - const lastDate = calculateLastInstallmentDate( - period, - currentInstallment, - installmentCount, - ); - - return formatLastInstallmentDate(lastDate); -}; - -const buildProgress = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount || installmentCount <= 0) { - return 0; - } - - return Math.min( - 100, - Math.max(0, (currentInstallment / installmentCount) * 100), - ); -}; - -export function InstallmentExpensesWidget({ - data, -}: InstallmentExpensesWidgetProps) { - if (data.expenses.length === 0) { - return ( - } - title="Nenhuma despesa parcelada" - description="Lançamentos parcelados aparecerão aqui conforme forem registrados." - /> - ); - } - - return ( - -
    - {data.expenses.map((expense) => { - const compactLabel = buildCompactInstallmentLabel( - expense.currentInstallment, - expense.installmentCount, - ); - const isLast = isLastInstallment( - expense.currentInstallment, - expense.installmentCount, - ); - const remainingInstallments = calculateRemainingInstallments( - expense.currentInstallment, - expense.installmentCount, - ); - const remainingAmount = calculateRemainingAmount( - expense.amount, - expense.currentInstallment, - expense.installmentCount, - ); - const endDate = formatEndDate( - expense.period, - expense.currentInstallment, - expense.installmentCount, - ); - const progress = buildProgress( - expense.currentInstallment, - expense.installmentCount, - ); - - return ( -
  • -
    -
    -
    -

    - {expense.name} -

    - {compactLabel && ( - - {compactLabel} - {isLast && ( - - - - Última parcela - Última parcela - - - - Última parcela! - - - )} - - )} -
    - -
    - -

    - {endDate && `Termina em ${endDate}`} - {" | Restante "} - {" "} - ({remainingInstallments}) -

    - - -
    -
  • - ); - })} -
-
- ); -} diff --git a/components/dashboard/invoices-widget.tsx b/components/dashboard/invoices-widget.tsx deleted file mode 100644 index 22cee07..0000000 --- a/components/dashboard/invoices-widget.tsx +++ /dev/null @@ -1,584 +0,0 @@ -"use client"; -import { - RiBillLine, - RiCheckboxCircleFill, - RiCheckboxCircleLine, - RiExternalLinkLine, - RiLoader4Line, - RiMoneyDollarCircleLine, -} from "@remixicon/react"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useTransition } from "react"; -import { toast } from "sonner"; -import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions"; -import MoneyValues from "@/components/money-values"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter as ModalFooter, -} from "@/components/ui/dialog"; -import type { DashboardInvoice } from "@/lib/dashboard/invoices"; -import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas"; -import { getAvatarSrc } from "@/lib/pagadores/utils"; -import { formatPeriodForUrl } from "@/lib/utils/period"; -import { Badge } from "../ui/badge"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "../ui/hover-card"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type InvoicesWidgetProps = { - invoices: DashboardInvoice[]; -}; - -type ModalState = "idle" | "processing" | "success"; - -const DUE_DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - timeZone: "UTC", -}); - -const resolveLogoPath = (logo: string | null) => { - if (!logo) { - return null; - } - if (/^(https?:\/\/|data:)/.test(logo)) { - return logo; - } - return logo.startsWith("/") ? logo : `/logos/${logo}`; -}; - -const buildInitials = (value: string) => { - const parts = value.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) { - return "CC"; - } - if (parts.length === 1) { - const firstPart = parts[0]; - return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC"; - } - const firstChar = parts[0]?.[0] ?? ""; - const secondChar = parts[1]?.[0] ?? ""; - return `${firstChar}${secondChar}`.toUpperCase() || "CC"; -}; - -const parseDueDate = (period: string, dueDay: string) => { - const [yearStr, monthStr] = period.split("-"); - const dayNumber = Number.parseInt(dueDay, 10); - const year = Number.parseInt(yearStr ?? "", 10); - const month = Number.parseInt(monthStr ?? "", 10); - - if ( - Number.isNaN(dayNumber) || - Number.isNaN(year) || - Number.isNaN(month) || - period.length !== 7 - ) { - return { - label: `Vence dia ${dueDay}`, - date: null, - }; - } - - const date = new Date(Date.UTC(year, month - 1, dayNumber)); - return { - label: `Vence em ${DUE_DATE_FORMATTER.format(date)}`, - date, - }; -}; - -const formatPaymentDate = (value: string | null) => { - if (!value) { - return null; - } - - const [yearStr, monthStr, dayStr] = value.split("-"); - const year = Number.parseInt(yearStr ?? "", 10); - const month = Number.parseInt(monthStr ?? "", 10); - const day = Number.parseInt(dayStr ?? "", 10); - - if ( - Number.isNaN(year) || - Number.isNaN(month) || - Number.isNaN(day) || - yearStr?.length !== 4 || - monthStr?.length !== 2 || - dayStr?.length !== 2 - ) { - return null; - } - - const date = new Date(Date.UTC(year, month - 1, day)); - return { - label: `Pago em ${DUE_DATE_FORMATTER.format(date)}`, - }; -}; - -const getTodayDateString = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -const formatSharePercentage = (value: number) => { - if (!Number.isFinite(value) || value <= 0) { - return "0%"; - } - const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2; - return `${value.toLocaleString("pt-BR", { - minimumFractionDigits: digits, - maximumFractionDigits: digits, - })}%`; -}; - -const getShareLabel = (amount: number, total: number) => { - if (total <= 0) { - return "0% do total"; - } - const percentage = (amount / total) * 100; - return `${formatSharePercentage(percentage)} do total`; -}; - -export function InvoicesWidget({ invoices }: InvoicesWidgetProps) { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [items, setItems] = useState(invoices); - const [isModalOpen, setIsModalOpen] = useState(false); - const [selectedId, setSelectedId] = useState(null); - const [modalState, setModalState] = useState("idle"); - - useEffect(() => { - setItems(invoices); - }, [invoices]); - - const selectedInvoice = useMemo( - () => items.find((invoice) => invoice.id === selectedId) ?? null, - [items, selectedId], - ); - - const selectedLogo = useMemo( - () => (selectedInvoice ? resolveLogoPath(selectedInvoice.logo) : null), - [selectedInvoice], - ); - - const selectedPaymentInfo = useMemo( - () => (selectedInvoice ? formatPaymentDate(selectedInvoice.paidAt) : null), - [selectedInvoice], - ); - - const handleOpenModal = (invoiceId: string) => { - setSelectedId(invoiceId); - setModalState("idle"); - setIsModalOpen(true); - }; - - const handleCloseModal = () => { - setIsModalOpen(false); - setModalState("idle"); - setSelectedId(null); - }; - - const handleConfirmPayment = () => { - if (!selectedInvoice) { - return; - } - - setModalState("processing"); - - startTransition(async () => { - const result = await updateInvoicePaymentStatusAction({ - cartaoId: selectedInvoice.cardId, - period: selectedInvoice.period, - status: INVOICE_PAYMENT_STATUS.PAID, - }); - - if (result.success) { - toast.success(result.message); - setItems((previous) => - previous.map((invoice) => - invoice.id === selectedInvoice.id - ? { - ...invoice, - paymentStatus: INVOICE_PAYMENT_STATUS.PAID, - paidAt: getTodayDateString(), - } - : invoice, - ), - ); - setModalState("success"); - router.refresh(); - return; - } - - toast.error(result.error); - setModalState("idle"); - }); - }; - - const getStatusBadgeVariant = (status: string): "success" | "info" => { - const normalizedStatus = status.toLowerCase(); - if (normalizedStatus === "em aberto") { - return "info"; - } - return "success"; - }; - - return ( - <> - - {items.length === 0 ? ( - } - title="Nenhuma fatura para o período selecionado" - description="Quando houver cartões com compras registradas, eles aparecerão aqui." - /> - ) : ( -
    - {items.map((invoice) => { - const logo = resolveLogoPath(invoice.logo); - const initials = buildInitials(invoice.cardName); - const dueInfo = parseDueDate(invoice.period, invoice.dueDay); - const isPaid = - invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID; - const isOverdue = - !isPaid && dueInfo.date !== null && dueInfo.date < new Date(); - const paymentInfo = formatPaymentDate(invoice.paidAt); - - return ( -
  • -
    -
    - {logo ? ( - {`Logo - ) : ( - - {initials} - - )} -
    - -
    - {(() => { - const breakdown = invoice.pagadorBreakdown ?? []; - const hasBreakdown = breakdown.length > 0; - const linkNode = ( - - {invoice.cardName} - - - ); - - if (!hasBreakdown) { - return linkNode; - } - - const totalForShare = Math.abs(invoice.totalAmount); - - return ( - - - {linkNode} - - -

    - Distribuição por pagador -

    -
      - {breakdown.map((share, index) => ( -
    • - - - - {buildInitials(share.pagadorName)} - - -
      -

      - {share.pagadorName} -

      -

      - {getShareLabel( - share.amount, - totalForShare, - )} -

      -
      -
      - -
      -
    • - ))} -
    -
    -
    - ); - })()} -
    - {!isPaid ? {dueInfo.label} : null} - {isPaid && paymentInfo ? ( - - {paymentInfo.label} - - ) : null} -
    -
    -
    - -
    - -
    - -
    -
    -
  • - ); - })} -
- )} -
- - { - if (!open) { - handleCloseModal(); - return; - } - setIsModalOpen(true); - }} - > - { - if (modalState === "processing") { - event.preventDefault(); - return; - } - handleCloseModal(); - }} - onPointerDownOutside={(event) => { - if (modalState === "processing") { - event.preventDefault(); - } - }} - > - {modalState === "success" ? ( -
-
- -
-
- - Pagamento confirmado! - - - Atualizamos o status da fatura. O lançamento do pagamento - aparecerá no extrato em instantes. - -
- - - -
- ) : ( - <> - - Confirmar pagamento - - Revise os dados antes de confirmar. Vamos registrar a fatura - como paga. - - - - {selectedInvoice ? ( -
-
-
-
-
- {selectedLogo ? ( - {`Logo - ) : ( - - {buildInitials(selectedInvoice.cardName)} - - )} -
-
-

- Cartão -

-

- {selectedInvoice.cardName} -

-
-
-
- {selectedInvoice.paymentStatus !== - INVOICE_PAYMENT_STATUS.PAID ? ( -

- { - parseDueDate( - selectedInvoice.period, - selectedInvoice.dueDay, - ).label - } -

- ) : null} - {selectedInvoice.paymentStatus === - INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? ( -

- {selectedPaymentInfo.label} -

- ) : null} -
-
-
- -
-
-
- - - Valor da Fatura - -
- -
-
-
- - - Status - -
- - {INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]} - -
-
-
- ) : null} - - - - - - - )} -
-
- - ); -} diff --git a/components/dashboard/notes-widget.tsx b/components/dashboard/notes-widget.tsx deleted file mode 100644 index f982e81..0000000 --- a/components/dashboard/notes-widget.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { RiFileList2Line, RiPencilLine, RiTodoLine } from "@remixicon/react"; -import { useCallback, useMemo, useState } from "react"; -import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog"; -import { NoteDialog } from "@/components/anotacoes/note-dialog"; -import type { Note } from "@/components/anotacoes/types"; -import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; -import type { DashboardNote } from "@/lib/dashboard/notes"; -import { Badge } from "../ui/badge"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type NotesWidgetProps = { - notes: DashboardNote[]; -}; - -const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - timeZone: "UTC", -}); - -const buildDisplayTitle = (value: string) => { - const trimmed = value.trim(); - return trimmed.length ? trimmed : "Anotação sem título"; -}; - -const mapDashboardNoteToNote = (note: DashboardNote): Note => ({ - id: note.id, - title: note.title, - description: note.description, - type: note.type, - tasks: note.tasks, - arquivada: note.arquivada, - createdAt: note.createdAt, -}); - -const getTasksSummary = (note: DashboardNote) => { - if (note.type !== "tarefa") { - return "Nota"; - } - - const tasks = note.tasks ?? []; - const completed = tasks.filter((task) => task.completed).length; - return `${completed}/${tasks.length} concluídas`; -}; - -export function NotesWidget({ notes }: NotesWidgetProps) { - const [noteToEdit, setNoteToEdit] = useState(null); - const [isEditOpen, setIsEditOpen] = useState(false); - const [noteDetails, setNoteDetails] = useState(null); - const [isDetailsOpen, setIsDetailsOpen] = useState(false); - - const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]); - - const handleOpenEdit = useCallback((note: Note) => { - setNoteToEdit(note); - setIsEditOpen(true); - }, []); - - const handleOpenDetails = useCallback((note: Note) => { - setNoteDetails(note); - setIsDetailsOpen(true); - }, []); - - const handleEditOpenChange = useCallback((open: boolean) => { - setIsEditOpen(open); - if (!open) { - setNoteToEdit(null); - } - }, []); - - const handleDetailsOpenChange = useCallback((open: boolean) => { - setIsDetailsOpen(open); - if (!open) { - setNoteDetails(null); - } - }, []); - - return ( - <> - - {mappedNotes.length === 0 ? ( - } - title="Nenhuma anotação ativa" - description="Crie anotações para acompanhar lembretes e tarefas financeiras." - /> - ) : ( -
    - {mappedNotes.map((note) => ( -
  • -
    -

    - {buildDisplayTitle(note.title)} -

    -
    - - {getTasksSummary(note)} - -

    - {DATE_FORMATTER.format(new Date(note.createdAt))} -

    -
    -
    - -
    - - -
    -
  • - ))} -
- )} -
- - - - - - ); -} diff --git a/components/dashboard/payment-conditions-widget.tsx b/components/dashboard/payment-conditions-widget.tsx deleted file mode 100644 index 558180f..0000000 --- a/components/dashboard/payment-conditions-widget.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { - RiCheckLine, - RiLoader2Fill, - RiRefreshLine, - RiSlideshowLine, -} from "@remixicon/react"; -import type { ReactNode } from "react"; -import MoneyValues from "@/components/money-values"; -import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; -import { Progress } from "../ui/progress"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type PaymentConditionsWidgetProps = { - data: PaymentConditionsData; -}; - -const CONDITION_ICON_CLASSES = - "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"; - -const CONDITION_ICONS: Record = { - "À vista": , - Parcelado: , - Recorrente: , -}; - -const formatPercentage = (value: number) => - new Intl.NumberFormat("pt-BR", { - minimumFractionDigits: 0, - maximumFractionDigits: 1, - }).format(value); - -export function PaymentConditionsWidget({ - data, -}: PaymentConditionsWidgetProps) { - if (data.conditions.length === 0) { - return ( - } - title="Nenhuma despesa encontrada" - description="As distribuições por condição aparecerão conforme novos lançamentos." - /> - ); - } - - return ( -
-
    - {data.conditions.map((condition) => { - const Icon = - CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"]; - const percentageLabel = formatPercentage(condition.percentage); - - return ( -
  • -
    {Icon}
    - -
    -
    -

    - {condition.condition} -

    - -
    - -
    - - {condition.transactions}{" "} - {condition.transactions === 1 - ? "lançamento" - : "lançamentos"} - - {percentageLabel}% -
    - -
    - -
    -
    -
  • - ); - })} -
-
- ); -} diff --git a/components/dashboard/payment-methods-widget.tsx b/components/dashboard/payment-methods-widget.tsx deleted file mode 100644 index b894d05..0000000 --- a/components/dashboard/payment-methods-widget.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react"; -import MoneyValues from "@/components/money-values"; -import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods"; -import { getPaymentMethodIcon } from "@/lib/utils/icons"; -import { Progress } from "../ui/progress"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type PaymentMethodsWidgetProps = { - data: PaymentMethodsData; -}; - -const ICON_WRAPPER_CLASS = - "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"; - -const formatPercentage = (value: number) => - new Intl.NumberFormat("pt-BR", { - minimumFractionDigits: 0, - maximumFractionDigits: 1, - }).format(value); - -const resolveIcon = (paymentMethod: string | null | undefined) => { - if (!paymentMethod) { - return ; - } - - const icon = getPaymentMethodIcon(paymentMethod); - if (icon) { - return icon; - } - - return ; -}; - -export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) { - if (data.methods.length === 0) { - return ( - - } - title="Nenhuma despesa encontrada" - description="Cadastre despesas para visualizar a distribuição por forma de pagamento." - /> - ); - } - - return ( -
-
    - {data.methods.map((method) => { - const icon = resolveIcon(method.paymentMethod); - const percentageLabel = formatPercentage(method.percentage); - - return ( -
  • -
    {icon}
    - -
    -
    -

    - {method.paymentMethod} -

    - -
    - -
    - - {method.transactions}{" "} - {method.transactions === 1 ? "lançamento" : "lançamentos"} - - {percentageLabel}% -
    - -
    - -
    -
    -
  • - ); - })} -
-
- ); -} diff --git a/components/dashboard/payment-status-widget.tsx b/components/dashboard/payment-status-widget.tsx deleted file mode 100644 index 6a9fba5..0000000 --- a/components/dashboard/payment-status-widget.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { - RiCheckboxCircleLine, - RiHourglass2Line, - RiWallet3Line, -} from "@remixicon/react"; -import MoneyValues from "@/components/money-values"; -import { CardContent } from "@/components/ui/card"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; -import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status"; -import { Progress } from "../ui/progress"; - -type PaymentStatusWidgetProps = { - data: PaymentStatusData; -}; - -type CategorySectionProps = { - title: string; - total: number; - confirmed: number; - pending: number; -}; - -function CategorySection({ - title, - total, - confirmed, - pending, -}: CategorySectionProps) { - // Usa valores absolutos para calcular percentual corretamente - const absTotal = Math.abs(total); - const absConfirmed = Math.abs(confirmed); - const confirmedPercentage = - absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0; - - return ( -
-
- {title} - -
- - {/* Barra de progresso */} - - - {/* Status de confirmados e pendentes */} -
-
- - - confirmados -
- -
- - - pendentes -
-
-
- ); -} - -export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) { - const isEmpty = data.income.total === 0 && data.expenses.total === 0; - - if (isEmpty) { - return ( - - } - title="Nenhum valor a receber ou pagar no período" - description="Registre lançamentos para visualizar os valores confirmados e pendentes." - /> - - ); - } - - return ( - - - - {/* Linha divisória pontilhada */} -
- - - - ); -} diff --git a/components/dashboard/recurring-expenses-widget.tsx b/components/dashboard/recurring-expenses-widget.tsx deleted file mode 100644 index 2ac6f7c..0000000 --- a/components/dashboard/recurring-expenses-widget.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { RiRefreshLine } from "@remixicon/react"; -import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; -import { CardContent } from "@/components/ui/card"; -import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type RecurringExpensesWidgetProps = { - data: RecurringExpensesData; -}; - -const formatOccurrences = (value: number | null) => { - if (!value) { - return "Recorrência contínua"; - } - - return `${value} recorrências`; -}; - -export function RecurringExpensesWidget({ - data, -}: RecurringExpensesWidgetProps) { - if (data.expenses.length === 0) { - return ( - } - title="Nenhuma despesa recorrente" - description="Lançamentos recorrentes aparecerão aqui conforme forem registrados." - /> - ); - } - - return ( - -
    - {data.expenses.map((expense) => { - return ( -
  • - - -
    -
    -

    - {expense.name} -

    - - -
    - -
    - - {expense.paymentMethod} - - {formatOccurrences(expense.recurrenceCount)} -
    -
    -
  • - ); - })} -
-
- ); -} diff --git a/components/dashboard/section-cards.tsx b/components/dashboard/section-cards.tsx deleted file mode 100644 index 61bbd00..0000000 --- a/components/dashboard/section-cards.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { - RiArrowDownLine, - RiArrowDownSFill, - RiArrowUpLine, - RiArrowUpSFill, - RiCashLine, - RiIncreaseDecreaseLine, - RiSubtractLine, -} from "@remixicon/react"; -import { - Card, - CardAction, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import type { DashboardCardMetrics } from "@/lib/dashboard/metrics"; -import MoneyValues from "../money-values"; - -type SectionCardsProps = { - metrics: DashboardCardMetrics; -}; - -type Trend = "up" | "down" | "flat"; - -const TREND_THRESHOLD = 0.005; - -const CARDS = [ - { - label: "Receitas", - key: "receitas", - icon: RiArrowUpLine, - invertTrend: false, - }, - { - label: "Despesas", - key: "despesas", - icon: RiArrowDownLine, - invertTrend: true, - }, - { - label: "Balanço", - key: "balanco", - icon: RiIncreaseDecreaseLine, - invertTrend: false, - }, - { label: "Previsto", key: "previsto", icon: RiCashLine, invertTrend: false }, -] as const; - -const TREND_ICONS = { - up: RiArrowUpSFill, - down: RiArrowDownSFill, - flat: RiSubtractLine, -} as const; - -const getTrend = (current: number, previous: number): Trend => { - const diff = current - previous; - if (diff > TREND_THRESHOLD) return "up"; - if (diff < -TREND_THRESHOLD) return "down"; - return "flat"; -}; - -const getPercentChange = (current: number, previous: number): string => { - const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero - - if (Math.abs(previous) < EPSILON) { - if (Math.abs(current) < EPSILON) return "0%"; - return "—"; - } - - const change = ((current - previous) / Math.abs(previous)) * 100; - return Number.isFinite(change) && Math.abs(change) < 1000000 - ? `${change > 0 ? "+" : ""}${change.toFixed(1)}%` - : "—"; -}; - -const getTrendColor = (trend: Trend, invertTrend: boolean): string => { - if (trend === "flat") return ""; - const isPositive = invertTrend ? trend === "down" : trend === "up"; - return isPositive - ? "text-success border-success" - : "text-destructive border-destructive"; -}; - -export function SectionCards({ metrics }: SectionCardsProps) { - return ( -
- {CARDS.map(({ label, key, icon: Icon, invertTrend }) => { - const metric = metrics[key]; - const trend = getTrend(metric.current, metric.previous); - const TrendIcon = TREND_ICONS[trend]; - const trendColor = getTrendColor(trend, invertTrend); - - return ( - - - - - {label} - - - -
- - {getPercentChange(metric.current, metric.previous)} -
-
-
- -
- Mês anterior -
-
- -
-
-
- ); - })} -
- ); -} diff --git a/components/dot-icon.tsx b/components/dot-icon.tsx deleted file mode 100644 index 193ea4b..0000000 --- a/components/dot-icon.tsx +++ /dev/null @@ -1,11 +0,0 @@ -type DotIconProps = { - color: string; -}; - -export default function DotIcon({ color }: DotIconProps) { - return ( - - - - ); -} diff --git a/components/faturas/invoice-summary-card.tsx b/components/faturas/invoice-summary-card.tsx deleted file mode 100644 index c3dd004..0000000 --- a/components/faturas/invoice-summary-card.tsx +++ /dev/null @@ -1,361 +0,0 @@ -"use client"; - -import { RiEditLine } from "@remixicon/react"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useTransition } from "react"; -import { toast } from "sonner"; -import { - updateInvoicePaymentStatusAction, - updatePaymentDateAction, -} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions"; -import DotIcon from "@/components/dot-icon"; -import MoneyValues from "@/components/money-values"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - INVOICE_PAYMENT_STATUS, - INVOICE_STATUS_BADGE_VARIANT, - INVOICE_STATUS_DESCRIPTION, - INVOICE_STATUS_LABEL, - type InvoicePaymentStatus, -} from "@/lib/faturas"; -import { cn } from "@/lib/utils/ui"; -import { EditPaymentDateDialog } from "./edit-payment-date-dialog"; - -type InvoiceSummaryCardProps = { - cartaoId: string; - period: string; - cardName: string; - cardBrand: string | null; - cardStatus: string | null; - closingDay: string; - dueDay: string; - periodLabel: string; - totalAmount: number; - limitAmount: number | null; - invoiceStatus: InvoicePaymentStatus; - paymentDate: Date | null; - logo?: string | null; - actions?: React.ReactNode; -}; - -const BRAND_ASSETS: Record = { - visa: "/bandeiras/visa.svg", - mastercard: "/bandeiras/mastercard.svg", - amex: "/bandeiras/amex.svg", - american: "/bandeiras/amex.svg", - elo: "/bandeiras/elo.svg", - hipercard: "/bandeiras/hipercard.svg", - hiper: "/bandeiras/hipercard.svg", -}; - -const resolveBrandAsset = (brand: string) => { - const normalized = brand.trim().toLowerCase(); - - const match = ( - Object.keys(BRAND_ASSETS) as Array - ).find((entry) => normalized.includes(entry)); - - return match ? BRAND_ASSETS[match] : null; -}; - -const actionLabelByStatus: Record = { - [INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga", - [INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento", -}; - -const actionVariantByStatus: Record< - InvoicePaymentStatus, - "default" | "outline" -> = { - [INVOICE_PAYMENT_STATUS.PENDING]: "default", - [INVOICE_PAYMENT_STATUS.PAID]: "outline", -}; - -const formatDay = (value: string) => value.padStart(2, "0"); - -const resolveLogoPath = (logo?: string | null) => { - if (!logo) return null; - if ( - logo.startsWith("http://") || - logo.startsWith("https://") || - logo.startsWith("data:") - ) { - return logo; - } - - return logo.startsWith("/") ? logo : `/logos/${logo}`; -}; - -const getCardStatusDotColor = (status: string | null) => { - if (!status) return "bg-gray-400"; - const normalizedStatus = status.toLowerCase(); - if (normalizedStatus === "ativo" || normalizedStatus === "active") { - return "bg-success"; - } - return "bg-gray-400"; -}; - -export function InvoiceSummaryCard({ - cartaoId, - period, - cardName, - cardBrand, - cardStatus, - closingDay, - dueDay, - periodLabel, - totalAmount, - limitAmount, - invoiceStatus, - paymentDate: initialPaymentDate, - logo, - actions, -}: InvoiceSummaryCardProps) { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [paymentDate, setPaymentDate] = useState( - initialPaymentDate ?? new Date(), - ); - - // Atualizar estado quando initialPaymentDate mudar - useEffect(() => { - if (initialPaymentDate) { - setPaymentDate(initialPaymentDate); - } - }, [initialPaymentDate]); - - const logoPath = useMemo(() => resolveLogoPath(logo), [logo]); - - const brandAsset = useMemo( - () => (cardBrand ? resolveBrandAsset(cardBrand) : null), - [cardBrand], - ); - - const limitLabel = useMemo(() => { - if (typeof limitAmount !== "number") return "—"; - return limitAmount.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - maximumFractionDigits: 2, - }); - }, [limitAmount]); - - const targetStatus = - invoiceStatus === INVOICE_PAYMENT_STATUS.PAID - ? INVOICE_PAYMENT_STATUS.PENDING - : INVOICE_PAYMENT_STATUS.PAID; - - const handleAction = () => { - startTransition(async () => { - const result = await updateInvoicePaymentStatusAction({ - cartaoId, - period, - status: targetStatus, - paymentDate: - targetStatus === INVOICE_PAYMENT_STATUS.PAID - ? paymentDate.toISOString().split("T")[0] - : undefined, - }); - - if (result.success) { - toast.success(result.message); - router.refresh(); - return; - } - - toast.error(result.error); - }); - }; - - const handleDateChange = (newDate: Date) => { - setPaymentDate(newDate); - startTransition(async () => { - const result = await updatePaymentDateAction({ - cartaoId, - period, - paymentDate: newDate.toISOString().split("T")[0] ?? "", - }); - - if (result.success) { - toast.success(result.message); - router.refresh(); - return; - } - - toast.error(result.error); - }); - }; - - return ( - - -
- {logoPath ? ( -
- {`Logo -
- ) : cardBrand ? ( - - {cardBrand} - - ) : null} - -
-
- - {cardName} - -

- Fatura de {periodLabel} -

-
- {actions ?
{actions}
: null} -
-
-
- - - {/* Destaque Principal */} -
- - } - /> - - {INVOICE_STATUS_LABEL[invoiceStatus]} - - } - /> -
- - {/* Informações Gerais */} -
- Dia {formatDay(closingDay)} - } - /> - Dia {formatDay(dueDay)}} - /> - - {`Bandeira - {cardBrand} -
- ) : cardBrand ? ( - {cardBrand} - ) : ( - - ) - } - /> - - - {cardStatus} -
- ) : ( - - ) - } - /> -
- - - - {/* Ações */} -
-

- {INVOICE_STATUS_DESCRIPTION[invoiceStatus]} -

-
- - {invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && ( - - - - } - currentDate={paymentDate} - onDateChange={handleDateChange} - /> - )} -
-
- - - ); -} - -type DetailItemProps = { - label?: string; - value: React.ReactNode; - className?: string; -}; - -function DetailItem({ label, value, className }: DetailItemProps) { - return ( -
- {label && ( - - {label} - - )} -
{value}
-
- ); -} diff --git a/components/font-provider.tsx b/components/font-provider.tsx deleted file mode 100644 index ac57ae7..0000000 --- a/components/font-provider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { getFontVariable } from "@/public/fonts/font_index"; - -type FontContextValue = { - systemFont: string; - moneyFont: string; - setSystemFont: (key: string) => void; - setMoneyFont: (key: string) => void; -}; - -const FontContext = createContext(null); - -export function FontProvider({ - systemFont: initialSystemFont, - moneyFont: initialMoneyFont, - children, -}: { - systemFont: string; - moneyFont: string; - children: React.ReactNode; -}) { - const [systemFont, setSystemFontState] = useState(initialSystemFont); - const [moneyFont, setMoneyFontState] = useState(initialMoneyFont); - - const applyFontVars = useCallback((sys: string, money: string) => { - document.documentElement.style.setProperty( - "--font-app", - getFontVariable(sys), - ); - document.documentElement.style.setProperty( - "--font-money", - getFontVariable(money), - ); - }, []); - - useEffect(() => { - applyFontVars(systemFont, moneyFont); - }, [systemFont, moneyFont, applyFontVars]); - - const setSystemFont = useCallback((key: string) => { - setSystemFontState(key); - }, []); - - const setMoneyFont = useCallback((key: string) => { - setMoneyFontState(key); - }, []); - - const value = useMemo( - () => ({ systemFont, moneyFont, setSystemFont, setMoneyFont }), - [systemFont, moneyFont, setSystemFont, setMoneyFont], - ); - - return ( - <> -