mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
246 Commits
2de5101058
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
467f71493d | ||
|
|
0cec10ede3 | ||
|
|
a6fba5f953 | ||
|
|
18893bfe02 | ||
|
|
7fdf9e2876 | ||
|
|
7d0781b035 | ||
|
|
b9b843b9db | ||
|
|
01215b3124 | ||
|
|
d70223e7b3 | ||
|
|
6ea064e1bd | ||
|
|
9c0669a152 | ||
|
|
b2d4b29cb5 | ||
|
|
1df2ba787d | ||
|
|
e5d9b66cca | ||
|
|
37edb1b76d | ||
|
|
6288f5f8d4 | ||
|
|
57ac326c2a | ||
|
|
dccc18b1c1 | ||
|
|
0cb01a1d4c | ||
|
|
51652da4f8 | ||
|
|
7a74f9405e | ||
|
|
94bf93194f | ||
|
|
d55173e8c1 | ||
|
|
4a73088c09 | ||
|
|
eaa20448a8 | ||
|
|
367d78d43d | ||
|
|
2fc6d11d78 | ||
|
|
0f5c735be0 | ||
|
|
4bea6330bf | ||
|
|
8389752172 | ||
|
|
19b5aa00ee | ||
|
|
863ccc0fd2 | ||
|
|
29d99cbedb | ||
|
|
dbeb98bbe4 | ||
|
|
c0436dc2ac | ||
|
|
e1e76fadc0 | ||
|
|
9b2c15ef7d | ||
|
|
fbe3fceb9f | ||
|
|
39f3cd8b20 | ||
|
|
791fec7751 | ||
|
|
114e2b4011 | ||
|
|
f15a003cef | ||
|
|
7f07a9cbf6 | ||
|
|
5fa234884e | ||
|
|
b453b432ed | ||
|
|
7f05d2a681 | ||
|
|
b14f487824 | ||
|
|
5b03824a72 | ||
|
|
74dda549f5 | ||
|
|
137b63f256 | ||
|
|
f747405264 | ||
|
|
cbc17c8513 | ||
|
|
c41fafc319 | ||
|
|
0bc3f06b77 | ||
|
|
2f68bcf039 | ||
|
|
41dcd5cec9 | ||
|
|
6391f07eb6 | ||
|
|
ae9dd364c4 | ||
|
|
e005add233 | ||
|
|
6d81ff8b53 | ||
|
|
5d84ae928a | ||
|
|
ba05985725 | ||
|
|
3e80d5995b | ||
|
|
68daae7926 | ||
|
|
9413c470a8 | ||
|
|
ad1b0aa979 | ||
|
|
4d9a1c0a35 | ||
|
|
5635705c56 | ||
|
|
4c97ed569d | ||
|
|
22a88de993 | ||
|
|
9456aa98bc | ||
|
|
21c6a8d9d0 | ||
|
|
c29ffa9a12 | ||
|
|
8875de843b | ||
|
|
679ea752bb | ||
|
|
1161e97d9e | ||
|
|
55d7dedd9a | ||
|
|
ad2752b7b0 | ||
|
|
58db357cde | ||
|
|
99a9ff5512 | ||
|
|
5bcf4f69d3 | ||
|
|
95099c1a94 | ||
|
|
94912f7edc | ||
|
|
bf6adfa3f1 | ||
|
|
e4b9dd4254 | ||
|
|
f1907c8697 | ||
|
|
805bcb863d | ||
|
|
11b4f8940f | ||
|
|
fba9686fdb | ||
|
|
9b8ac9f71f | ||
|
|
fa41c78a39 | ||
|
|
5f7bfb98da | ||
|
|
9ecafdb15f | ||
|
|
e8cc673e52 | ||
|
|
3bd8117b65 | ||
|
|
a7268d8f05 | ||
|
|
1f9098879e | ||
|
|
7a3bff52ac | ||
|
|
dfb4126b12 | ||
|
|
ffead579fa | ||
|
|
aa85cf8b29 | ||
|
|
9a7ae0fa3d | ||
|
|
98fe6a0f4f | ||
|
|
d10eae13e5 | ||
|
|
43697b4fd2 | ||
|
|
27e3ba5f0d | ||
|
|
31485eec8f | ||
|
|
3be64aa8d0 | ||
|
|
85f6dcfc22 | ||
|
|
df996df93d | ||
|
|
10afef9fec | ||
|
|
fd4d90a53e | ||
|
|
a24406271c | ||
|
|
a09942e3d8 | ||
|
|
96febd5904 | ||
|
|
c3cfbc878c | ||
|
|
55bbfabe9f | ||
|
|
f5cdae4853 | ||
|
|
5c4995961c | ||
|
|
1b4dfaaba7 | ||
|
|
549a5bdba1 | ||
|
|
acaf9d5c27 | ||
|
|
e4c6a91350 | ||
|
|
ba369e8a83 | ||
|
|
d01bc8a669 | ||
|
|
e024e0d54e | ||
|
|
c44089169f | ||
|
|
d04e30e3c9 | ||
|
|
229b6c5bc0 | ||
|
|
c3b133d8d9 | ||
|
|
e9a2ab1782 | ||
|
|
c7d6e23398 | ||
|
|
0514efb1c4 | ||
|
|
e32fb85006 | ||
|
|
96df6a1798 | ||
|
|
1f8a97bd16 | ||
|
|
0ab3298cef | ||
|
|
cad41680eb | ||
|
|
3b00f328c5 | ||
|
|
20d0c3e0a7 | ||
|
|
71b5a004e3 | ||
|
|
65b1506d75 | ||
|
|
2a458d5a3c | ||
|
|
f418987f47 | ||
|
|
59b4dea071 | ||
|
|
6ce132fe0c | ||
|
|
49731238e4 | ||
|
|
c5df97f7aa | ||
|
|
3476fda4db | ||
|
|
519b673ae5 | ||
|
|
303b8bedd4 | ||
|
|
f2b9b16896 | ||
|
|
6eba35542b | ||
|
|
f5e95ffba6 | ||
|
|
a75bb86eec | ||
|
|
a3b858621f | ||
|
|
fee2a2c9f5 | ||
|
|
839d7d0866 | ||
|
|
7cd7d95245 | ||
|
|
9bd762f7a3 | ||
|
|
9b76db4ce9 | ||
|
|
91457b6490 | ||
|
|
a0a71623d7 | ||
|
|
00e624b8bc | ||
|
|
f82043127a | ||
|
|
32da4f906e | ||
|
|
0bd9d0ac47 | ||
|
|
9f45fd1ecd | ||
|
|
f528e75ee1 | ||
|
|
da32b41bbc | ||
|
|
1e0c93fb6c | ||
|
|
5f70421f5a | ||
|
|
50477fb1be | ||
|
|
60a52b9873 | ||
|
|
c9205f2be9 | ||
|
|
1d36b12109 | ||
|
|
19a1b1e943 | ||
|
|
d3fc81db73 | ||
|
|
80de9501f6 | ||
|
|
7dd480284e | ||
|
|
0a7c65ec9e | ||
|
|
05daac5f57 | ||
|
|
a893473388 | ||
|
|
3d850be60f | ||
|
|
dd8fd61c32 | ||
|
|
eef80b4daa | ||
|
|
6d891d3b29 | ||
|
|
4b6f791265 | ||
|
|
655fc64977 | ||
|
|
56a23c40cf | ||
|
|
9377e451de | ||
|
|
bdb3908dab | ||
|
|
a20fe255f3 | ||
|
|
deb7c775f8 | ||
|
|
3e0ce15258 | ||
|
|
e4dd221709 | ||
|
|
f77c64325d | ||
|
|
29551ee02f | ||
|
|
3c31ee5d90 | ||
|
|
41fd8226cb | ||
|
|
5b8d25d894 | ||
|
|
33a5d6f5f0 | ||
|
|
58f5a4ab2f | ||
|
|
a7c6f3c632 | ||
|
|
076953340f | ||
|
|
50177621ff | ||
|
|
272e90aef9 | ||
|
|
7064c0b0bc | ||
|
|
ff016113b9 | ||
|
|
36687debf2 | ||
|
|
fd702276d8 | ||
|
|
39711615ee | ||
|
|
2cb5033486 | ||
|
|
132f98c0f8 | ||
|
|
959db963b8 | ||
|
|
f4e7108119 | ||
|
|
69df314db7 | ||
|
|
fc86b9002e | ||
|
|
a4da0a7143 | ||
|
|
173fc86920 | ||
|
|
1823b6be56 | ||
|
|
e84becd1cd | ||
|
|
ca67d36f33 | ||
|
|
df3d0134be | ||
|
|
2712d4919a | ||
|
|
64eb29d807 | ||
|
|
5a78fd614c | ||
|
|
62b94e6b1d | ||
|
|
1e8e6e0d3d | ||
|
|
9fb3cc5ecd | ||
|
|
a143f70269 | ||
|
|
2f60ee6639 | ||
|
|
2d5375b2cc | ||
|
|
6854017a8c | ||
|
|
43b0f0c47e | ||
|
|
67ad4b9d02 | ||
|
|
ef918a3667 | ||
|
|
fa9bf17663 | ||
|
|
20c14aa96f | ||
|
|
b0fbb1062a | ||
|
|
d92e70f1b9 | ||
|
|
f724d8ac04 | ||
|
|
6205dee42a | ||
|
|
ada1377640 | ||
|
|
69da27276c | ||
|
|
3e06a1d056 |
26
.env.example
26
.env.example
@@ -3,10 +3,10 @@
|
||||
# ============================================
|
||||
|
||||
# === Database ===
|
||||
# PostgreSQL local (Docker): use host "db"
|
||||
# PostgreSQL local (sem Docker): use host "localhost"
|
||||
# Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
|
||||
# Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
|
||||
# PostgreSQL remoto: use URL completa do provider
|
||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db
|
||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||
|
||||
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
|
||||
POSTGRES_USER=openmonetis
|
||||
@@ -22,6 +22,13 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||
APP_PORT=3000
|
||||
DB_PORT=5432
|
||||
|
||||
# === S3 Server (Opcional) ===
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
|
||||
# === Email (Opcional) ===
|
||||
# Provider: Resend (https://resend.com)
|
||||
RESEND_API_KEY=
|
||||
@@ -37,8 +44,19 @@ GOOGLE_CLIENT_SECRET=
|
||||
# Se não definido, todas as rotas ficam acessíveis.
|
||||
# PUBLIC_DOMAIN=openmonetis.com
|
||||
|
||||
# === Analytics (Opcional) ===
|
||||
# Umami: https://umami.is — self-hosted ou cloud
|
||||
UMAMI_URL=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_DOMAINS=
|
||||
|
||||
# === AI Providers (Opcional) ===
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# === Logo.dev (Opcional) ===
|
||||
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
|
||||
LOGO_DEV_TOKEN=
|
||||
LOGO_DEV_SECRET_KEY=
|
||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Força LF para arquivos que precisam de line endings Unix no container
|
||||
*.sh text eol=lf
|
||||
docker-entrypoint.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
58
.github/copilot-instructions.md
vendored
58
.github/copilot-instructions.md
vendored
@@ -1,58 +0,0 @@
|
||||
# AI Coding Assistant Instructions for OpenMonetis
|
||||
|
||||
## Project Overview
|
||||
|
||||
OpenMonetis is a self-hosted personal finance management application built with Next.js 16, TypeScript, PostgreSQL, and Drizzle ORM. It provides manual transaction tracking, account management, budgeting, and financial insights with a Portuguese interface.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: Next.js App Router with React 19, shadcn/ui components, Tailwind CSS
|
||||
- **Backend**: Server actions in Next.js, API routes for auth/health
|
||||
- **Database**: PostgreSQL with Drizzle ORM, schema in `db/schema.ts`
|
||||
- **Auth**: Better Auth (OAuth + email magic links)
|
||||
- **Deployment**: Docker multi-stage build, health checks
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Server Actions**: Use `"use server"` for mutations, validate with Zod schemas, handle errors with `handleActionError`
|
||||
- **Database Queries**: Use Drizzle's query API with relations, e.g., `db.query.lancamentos.findMany({ with: { categoria: true } })`
|
||||
- **Authentication**: Import from `lib/auth/server`, redirect on failure
|
||||
- **Revalidation**: Call `revalidateForEntity("lancamentos")` after mutations
|
||||
- **Portuguese Naming**: DB fields like `nome`, `tipo_conta`, `pagador` (payer), `lancamento` (transaction)
|
||||
- **Component Structure**: Feature-based folders in `components/`, shared UI in `components/ui/`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- **Start Dev**: `pnpm dev` (Turbopack), `docker compose up db -d` for DB
|
||||
- **Database**: `pnpm db:push` to sync schema, `pnpm db:studio` for visual editor
|
||||
- **Build**: `pnpm build`, `pnpm start` for production
|
||||
- **Docker**: `pnpm docker:up` for full stack, `pnpm docker:logs` for monitoring
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- **Add Transaction**: Create server action in `app/(dashboard)/lancamentos/actions.ts`, validate with Zod, insert via Drizzle
|
||||
- **New Entity**: Add to `db/schema.ts`, define relations, create CRUD actions in `lib/[entity]/actions.ts`
|
||||
- **UI Component**: Use shadcn/ui, place in `components/[feature]/`, export from `components/ui/`
|
||||
- **API Route**: Add to `app/api/`, use `getUserSession()` for auth
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Imports**: Absolute paths with `@/`, group by external/internal
|
||||
- **Error Handling**: Return `{ success: false, error: string }` from actions
|
||||
- **Currency**: Store as decimal strings (e.g., "123.45"), convert to cents for calculations
|
||||
- **Periods**: Format as "YYYY-MM", use `parsePeriodParam()` for URL params
|
||||
- **Notifications**: Send emails via `sendPagadorAutoEmails()` for payer updates
|
||||
|
||||
## External Integrations
|
||||
|
||||
- **Better Auth**: Config in `lib/auth/config.ts`, session handling
|
||||
- **Drizzle**: Migrations in `drizzle/`, studio at `pnpm db:studio`
|
||||
- **AI Features**: Use `@ai-sdk/*` for insights, configured in environment
|
||||
- **Email**: Resend for notifications, configured via `RESEND_API_KEY`
|
||||
|
||||
## File Examples
|
||||
|
||||
- Schema: `db/schema.ts` (relations, indexes)
|
||||
- Actions: `app/(dashboard)/lancamentos/actions.ts` (CRUD with validation)
|
||||
- Components: `components/lancamentos/page/lancamentos-page.tsx` (client component)
|
||||
- Utils: `lib/lancamentos/page-helpers.ts` (data transformation)
|
||||
25
.github/workflows/docker-publish.yml
vendored
25
.github/workflows/docker-publish.yml
vendored
@@ -13,10 +13,35 @@ on:
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE_NAME: openmonetis
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
62
.github/workflows/release.yml
vendored
Normal file
62
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read version from package.json
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: tag_check
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract changelog for this version
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.value }}"
|
||||
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
|
||||
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
# Remove linhas em branco do início e fim
|
||||
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create tag and GitHub Release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
body: ${{ steps.changelog.outputs.notes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -104,11 +104,11 @@ docker-compose.override.yml
|
||||
.claude/
|
||||
.gemini/
|
||||
.cursor/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
QWEN.md
|
||||
claude.md
|
||||
agents.md
|
||||
AGENTS.md
|
||||
.codex
|
||||
# === Backups locais ===
|
||||
/backup/
|
||||
|
||||
# === Backups e Temporários ===
|
||||
*.bak
|
||||
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -12,7 +12,6 @@
|
||||
"**/.next": true,
|
||||
".next": true
|
||||
},
|
||||
"explorerExclude.backup": {},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
@@ -25,7 +24,9 @@
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"eslint.enable": false,
|
||||
"prettier.enable": false,
|
||||
"typescript.preferences.organizeImportsCollation": "ordinal"
|
||||
"editor.fontSize": 15,
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
667
CHANGELOG.md
667
CHANGELOG.md
@@ -5,10 +5,629 @@ 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.5.5] - 2026-05-06
|
||||
|
||||
Esta versão melhora a navegação por históricos e lançamentos. O changelog ganhou uma linha do tempo mais leve, colapsável e fácil de escanear; os filtros de lançamentos passam a aceitar múltiplas pessoas, categorias, formas de pagamento, condições e contas/cartões na mesma busca; e os diálogos adotam as animações compartilhadas do design system. Também há pequenos polimentos de texto e layout para deixar a interface mais consistente.
|
||||
|
||||
### Adicionado
|
||||
- Lançamentos: filtros multi-seleção para condição, forma de pagamento, pessoa, categoria e conta/cartão, permitindo combinar vários valores no mesmo filtro (query string passa a aceitar múltiplos valores por chave).
|
||||
- Changelog: parser passou a inferir o tipo de bump (major/minor/patch) a partir da numeração e a extrair o parágrafo de resumo abaixo do cabeçalho de versão; novo arquivo `src/features/settings/lib/changelog-types.ts` consolidando os tipos compartilhados.
|
||||
- UI: dependência `tw-animate-css` para usar as mesmas animações utilitárias já presentes nos componentes shadcn/ui.
|
||||
|
||||
### Alterado
|
||||
- Changelog: visual da página reformulado para linha do tempo com resumo sempre visível, detalhes colapsáveis por versão, agrupamento por mês e marcadores visuais por tipo de bump; componente migrado para `"use client"` com `Collapsible` e abertura via âncora (`#vX-Y-Z`).
|
||||
- Lançamentos: botões "Nova Receita" e "Nova Despesa" agora usam os próprios triggers do `TransactionDialog` (via prop `createSlot`), reduzindo estado manual na página e eliminando o fluxo `setCreateOpen` + `transactionTypeForCreate`.
|
||||
- Diálogos: animações customizadas em CSS (`@keyframes dialog-in/out` e `overlay-in/out`) substituídas pelas classes utilitárias compartilhadas em `Dialog`/`DialogOverlay` (`data-[state=open]:animate-in`, `zoom-in-95`, `fade-in-0`).
|
||||
- BulkActionDialog: label do escopo "Todas as pessoas" passa a indicar a parcela atual (`Todas as pessoas desta parcela (N/Total)`) com descrição mais clara sobre o efeito da ação.
|
||||
- Checkbox: `RiCheckLine`/`RiSubtractLine` agora herdam `text-current` para alinhar com a cor do indicator nativo.
|
||||
- Landing page: remoção de fundos alternados (`bg-muted/40`) nas seções "Funcionalidades", "Stack" e "Para quem é" para uma leitura visual mais limpa.
|
||||
- Navbar: aviso de atualização passa a usar o texto "Versão X disponível".
|
||||
|
||||
## [2.5.4] - 2026-05-06
|
||||
|
||||
Esta versão é uma faxina arquitetural de larga escala sem nenhuma mudança visível ao usuário. Removido código morto, padronizamos identificadores em inglês conforme a convenção do projeto, simplificamos o barrel de Server Actions e consolidamos os arquivos de helpers/queries soltos nas raízes das features dentro de pastas `lib/`. O resultado é uma estrutura previsível e consistente entre features (`actions.ts`, `queries.ts`, `actions/`, `components/`, `hooks/`, `lib/`) e um saldo líquido de −428 linhas de código com zero impacto em comportamento, performance ou banco de dados.
|
||||
|
||||
### Alterado
|
||||
- Padronização da estrutura de `transactions/`: 14 helpers soltos na raiz movidos para `lib/`; barrel `actions.ts` reduzido de 76 linhas de wrappers redundantes para 14 linhas de re-exports puros; `anticipation-actions.ts` movido para `actions/anticipation.ts`.
|
||||
- Reorganização de `dashboard/`: 8 helpers soltos consolidados em `dashboard/lib/`; orquestradores (`fetch-dashboard-data.ts`, `page-data-queries.ts`) permanecem na raiz como entry points.
|
||||
- Reorganização de `reports/`: 5 query files na raiz consolidados em `reports/lib/`.
|
||||
- Reorganização de `payers/`: god file `detail-actions.ts` (21KB) e `detail-queries.ts` movidos para `payers/lib/`.
|
||||
- `shared/components/`: 9 dos 16 componentes soltos agrupados em 3 novas subpastas temáticas (`brand/`, `widgets/`, `feedback/`).
|
||||
- `shared/lib/fetch-json.ts` movido para `shared/utils/fetch-json.ts` (categorização correta — utilitário genérico de transporte HTTP).
|
||||
- Padronização EN dos identificadores remanescentes: 4 constantes globais (`LANCAMENTOS_*` → `TRANSACTIONS_*`), 12 tipos/interfaces (`Lancamento*`/`Pagador*`/`Estabelecimento*` → equivalentes em EN), 13 funções/components exportados (`fetchPagador*`, `EstabelecimentoInput`, `PagadorInfoCard`, etc.), 5 props cross-file (`preLancamentosCount` → `inboxPendingCount`, etc.).
|
||||
- Server Actions de `insights/` simplificadas: barrel reduzido para re-exports puros.
|
||||
- Mantidas intencionalmente em PT-BR conforme exceção do `CLAUDE.md`: variáveis locais (`pagador`, `categoria`, `lancamento`), accessor key `pagadorName` (persistida em preferências do usuário), strings de UI.
|
||||
|
||||
### Removido
|
||||
- 14 funções/constantes mortas verificadas via `grep` em todo o repo: `validateCategoriaOwnership`, `getInstallmentAnticipationsAction`, `getAnticipationDetailsAction`, `formatDecimalForDb`, `currencyFormatterNoCents`, `optionalDecimalSchema`, `formatMonthLabel`, `getGoalProgressStatusColorClass`, `MONTH_PERIOD_PARAM`, `calculateRemainingInstallments`, e 5 funções `fetch*` não usadas em `inbox/queries.ts`.
|
||||
- 1 tipo morto: `ImportRow` em `transactions/actions/import-action.ts`.
|
||||
- 2 tipos órfãos consequentes: `InstallmentAnticipationWithRelations`, `GoalProgressStatus` (este último convertido em interno).
|
||||
- ~30 `export` keywords desnecessários (símbolos usados apenas no próprio arquivo) — visibilidade reduzida sem mudar comportamento.
|
||||
- Re-exports mortos em barrels: `EstablishmentLogoPicker` em `entity-avatar/index.ts`, `CategoryReportSkeleton` e `WidgetSkeleton` em `skeletons/index.ts`, `toNameKey` em `establishment-logo-queries.ts`.
|
||||
- Arquivo `features/reports/types.ts` (barrel inteiro era órfão — todos os 5 tipos eram importados direto de `@/shared/lib/types/reports`).
|
||||
|
||||
## [2.5.3] - 2026-05-05
|
||||
|
||||
Esta versão foca em polimento do diálogo de detalhes do lançamento, refresh visual da linha do tempo de parcelas e limpeza terminológica em torno de contas/cartões inativos. O diálogo de detalhes ganhou logo da conta/cartão, ícone colorido por categoria e avatar do responsável; a barra de progresso de parcelas foi redesenhada num layout horizontal compacto; e o widget "Minhas Contas" do dashboard passou a ocultar automaticamente contas marcadas como inativas. Internamente, o termo "arquivadas" foi padronizado como "inativas" nas tabs de contas e cartões, surgiram constantes compartilhadas para formas de pagamento liquidáveis e um helper `isAccountInactive`, e o seed de mock data ganhou cobertura mais realista (novas pessoas, contas, cartões e assinaturas recorrentes).
|
||||
|
||||
### Adicionado
|
||||
- Logo da conta/cartão, ícone colorido por categoria e avatar do responsável no diálogo de detalhes do lançamento.
|
||||
- Constantes `SETTLEABLE_PAYMENT_METHODS` e `CREDIT_CARD_PAYMENT_METHOD` em `features/transactions/constants.ts`.
|
||||
- Helper `isAccountInactive(status)` em `shared/lib/accounts/constants.ts`, reaproveitado em `account-card.tsx` e `my-accounts-widget.tsx`.
|
||||
|
||||
### Alterado
|
||||
- Widget "Minhas Contas" do dashboard agora oculta contas inativas (filtra antes de aplicar a regra de "não consideradas") e ajusta o empty state quando o usuário só tem contas inativas.
|
||||
- Linha do tempo de parcelas (`InstallmentTimeline`) redesenhada: layout horizontal com barra de progresso, datas de compra e quitação alinhadas nas pontas e contador "N restante(s)" / "Última parcela" abaixo.
|
||||
- Diálogo de detalhes do lançamento: badge de status "Pendente" virou "Em aberto" com variante `info`, "Resumo" virou "Total" e ID do lançamento passou a exibir o UUID completo em fonte monoespaçada (sem truncar).
|
||||
- Tabs em contas e cartões: "Arquivadas/Arquivados" renomeadas para "Inativas/Inativos".
|
||||
- Legenda do calendário envolvida em `Card` para destacar visualmente do conteúdo da página.
|
||||
- Páginas `cards`, `categories`, `inbox`, `notes`, `payers` perderam `items-start` no `<main>` (alinhamento natural à largura total); `calendar` ajustou gap de 3 para 4.
|
||||
- Tabela de lançamentos: extraído IIFE de payment-method dos botões de liquidação com as novas constantes compartilhadas; bloco logo+label da coluna Conta/Cartão deduplicado via reuso de variável JSX; removido `capitalize` redundante do label "Venc.".
|
||||
- Mock data renovado em `scripts/mock-data.ts`: novas pessoas (Mario), novas contas (Itaú Personnalité, Banco Inter), novo cartão Inter Black, e cobertura mais ampla de assinaturas recorrentes (Vivo, Sabesp, Disney+, HBO Max, Amazon Prime, OpenAI, Apple iCloud, Notion, YouTube Premium).
|
||||
|
||||
### Removido
|
||||
- Comentário narrativo `{/* Opções de Antecipação */}` em `transactions-columns.tsx`.
|
||||
- Helper local `shortTransactionId` em `transaction-details-dialog.tsx` (substituído pela exibição do UUID completo).
|
||||
|
||||
## [2.5.2] - 2026-05-04
|
||||
|
||||
Esta versão traz melhorias visuais e de usabilidade em contas, lançamentos, orçamentos, cartões e anotações: novos tipos de conta, ícones no seletor, feedback visual de limite excedido nas progress bars e refinamentos nos ícones de tarefas em anotações.
|
||||
|
||||
### Adicionado
|
||||
- Novos tipos de conta `"Dinheiro"` e `"Outros"` na lista padrão do diálogo de contas (issue #50).
|
||||
- Ícones por tipo de conta no seletor (Conta Corrente, Poupança, Carteira Digital, Investimento, Pré-Pago, Dinheiro, Outros).
|
||||
- Filtro automático: ao selecionar `"Dinheiro"` como forma de pagamento em lançamentos, o select de conta exibe apenas contas do tipo `"Dinheiro"`.
|
||||
- Sinal `+` no valor de transferências recebidas na tabela de lançamentos (mantém cor azul).
|
||||
|
||||
### Alterado
|
||||
- Forma de pagamento de novas transferências entre contas alterada de `"Pix"` para `"Transferência bancária"`.
|
||||
- Progress bar de orçamentos excedidos agora exibe indicador e fundo na cor `destructive`.
|
||||
- Progress bar de cartões com 100% do limite utilizado agora exibe indicador e fundo na cor `destructive`.
|
||||
- Ícone de tarefa não concluída no card e no modal de detalhes de anotações substituído por `RiSubtractLine` (locais sem interação de marcação).
|
||||
|
||||
## [2.5.1] - 2026-05-04
|
||||
|
||||
Versão de correção pontual focada na exibição do indicador de anexo nas tabelas de lançamentos da fatura do cartão. Em `/cards/[cardId]/invoice`, lançamentos com anexos não mostravam o ícone porque o fetcher dedicado da fatura não calculava o flag `hasAttachments`. A primeira tentativa de adicionar o EXISTS via `extras` na query relacional gerou SQL inválido (Drizzle re-aliasava `transactionAttachments.transactionId` para o alias da tabela externa). A correção definitiva troca o fetcher pela função compartilhada `fetchTransactionsWithRelations` de `features/transactions`, que já implementa o EXISTS corretamente via `select`.
|
||||
|
||||
### Corrigido
|
||||
- Ícone de anexo voltou a aparecer na tabela de lançamentos da fatura do cartão (`/cards/[cardId]/invoice`). `fetchCardTransactions` em `features/invoices/queries.ts` agora delega para `fetchTransactionsWithRelations`, garantindo que o flag `hasAttachments` seja preenchido com a mesma EXISTS subquery usada no restante do app.
|
||||
|
||||
## [2.5.0] - 2026-05-01
|
||||
|
||||
Esta versão melhora o fechamento de faturas, a correção de lançamentos já registrados e a conferência de saldos contra o extrato do banco. O novo **ajuste de fatura** fecha a conta entre o total calculado pelo sistema e o valor real cobrado pelo banco, sem exigir que o usuário reabra lançamentos individuais. A mesma ideia foi estendida para **contas correntes**: na página do extrato, ao lado de "Saldo ao final do período", o usuário informa o saldo real e o sistema cria (ou atualiza) um lançamento de ajuste no período visualizado. Também entra o fluxo de **reembolso** para despesas à vista: pelo menu de ações do lançamento, o usuário informa a data do reembolso e o sistema cria uma receita espelhada no extrato ou na fatura correta. O widget de boletos do dashboard ganhou paridade com o widget de faturas — confirmação de pagamento agora pede conta de origem e data antes de quitar o boleto. Por fim, o **limite do cartão** passou a ser obrigatório e o sistema bloqueia despesas em cartão que ultrapassem o limite disponível, retornando uma mensagem com o valor exato disponível. As operações mantêm rastro no lançamento gerado e respeitam a proteção de faturas já pagas.
|
||||
|
||||
### Adicionado
|
||||
- Nome do boleto no widget de Boletos agora é um link para `/transactions?q=<nome>`, incluindo `?periodo=<mes-ano>` automaticamente quando o período selecionado não é o atual. Ícone `RiExternalLinkLine` ao lado do nome, igual ao padrão do widget de Faturas.
|
||||
- Botão "Ajustar fatura" ao lado do valor na página da fatura.
|
||||
- Dialog `AdjustInvoiceDialog` com input de valor correto e preview da diferença.
|
||||
- Action `adjustInvoiceAction` que faz upsert/delete idempotente do lançamento de ajuste.
|
||||
- Botão "Ajustar saldo" ao lado do valor na página do extrato da conta.
|
||||
- Dialog `AdjustBalanceDialog` com input do saldo correto e preview da diferença que será lançada (receita ou despesa).
|
||||
- Action `adjustAccountBalanceAction` que faz upsert/delete idempotente do lançamento de ajuste por `(accountId, period)`.
|
||||
- Opção "Reembolso" no dropdown de ações de despesas à vista, posicionada após "Copiar" e antes de "Remover".
|
||||
- Dialog `RefundTransactionDialog` com seleção da data do reembolso e indicação do período de destino.
|
||||
- Action `refundTransactionAction` que cria uma receita de reembolso vinculada ao lançamento original.
|
||||
- Constantes compartilhadas `INVOICE_ADJUSTMENT_NAME`, `ACCOUNT_BALANCE_ADJUSTMENT_NAME`, `REFUND_NOTE_PREFIX` e `buildRefundNote()` em `shared/lib/accounts/constants.ts`.
|
||||
- Validação de limite de cartão: `validateCardLimit()` em `transactions/actions/core.ts` calcula o uso atual do cartão (somando lançamentos não quitados, com a mesma regra usada em `cards/queries.ts` para recorrentes) e bloqueia criação ou edição de despesa em cartão que ultrapasse o disponível, retornando "Lançamento de R$ X excede o limite disponível do cartão (R$ Y)."
|
||||
- Schema reutilizável `requiredDecimalSchema(fieldName)` em `shared/lib/schemas/common.ts` — número/string positiva (`> 0`) com mensagens parametrizáveis.
|
||||
|
||||
### Alterado
|
||||
- **Limite do cartão é obrigatório**: campo `limite` em `cartoes` ganhou `NOT NULL DEFAULT 0` no schema, validação Zod com `requiredDecimalSchema("limite")`, atributo `required` no input do formulário e checagem client-side antes do submit. Tipos `Card.limit` e `Card.limitAvailable` deixam de ser nullable; branch "Ainda não há limite registrado" foi removido de `card-item.tsx` e a derivação defensiva em `cards/[cardId]/invoice` foi simplificada.
|
||||
- Migration `0029_friendly_spitfire`: preenche com `0` registros legados antes do `SET NOT NULL` para não quebrar bancos com cartões sem limite.
|
||||
- Métricas principais passam a tratar reembolsos como abatimento de despesa, não como receita comum.
|
||||
- Cards de receitas/despesas, série histórica do dashboard e resumo do extrato agora preservam o efeito líquido do reembolso no balanço sem inflar entradas e saídas.
|
||||
- Pagamento de fatura agora abre confirmação com conta de origem selecionável; por padrão vem a conta vinculada ao cartão, mas o usuário pode escolher outra conta antes de confirmar.
|
||||
- Widget de faturas no dashboard ganhou a mesma confirmação: o modal "Confirmar pagamento" agora pede conta de origem e data antes de marcar a fatura como paga, alinhando o comportamento ao da página de fatura.
|
||||
- Widget de boletos no dashboard ganhou a mesma paridade: o modal "Confirmar pagamento" passou a oferecer seleção de **conta de pagamento** e **data do pagamento**, com mesma estrutura de cards de detalhes, métricas, separator e formulário condicional do widget de faturas.
|
||||
- `toggleTransactionSettlementAction` agora aceita `paymentAccountId` e `paymentDate` opcionais para boletos — quando informados, atualiza a `accountId` do lançamento e usa a data escolhida em `boletoPaymentDate` (em vez da data atual).
|
||||
- `DashboardBill` passa a expor `accountId` para que o dialog inicialize a conta com o valor já vinculado ao boleto.
|
||||
- Widget "Lançamentos por Categorias" agora ignora a categoria "Transferência interna" — transferências entre contas próprias deixam de poluir o ranking de categorias.
|
||||
|
||||
### Corrigido
|
||||
- Erro de hidratação no widget de Anotações: `Intl.DateTimeFormat` sem `timeZone` usava o fuso do servidor (UTC) no SSR e o fuso do browser (BRT) no cliente, resultando em datas divergentes. Ambos os formatters passam a usar `timeZone: "America/Sao_Paulo"` explicitamente.
|
||||
- Extrato da conta agora contabiliza transferências internas nos cards de **Entradas** e **Saídas**: transferência recebida soma em Entradas, transferência enviada soma em Saídas. Antes o saldo final refletia o movimento mas os cards permaneciam zerados, gerando inconsistência visível na tela (issue #47).
|
||||
|
||||
### Removido
|
||||
- Seção "Veja o que você pode fazer" (galeria de screenshots com abas) da landing page, junto com o componente `ScreenshotTabs`, as 14 imagens `preview-*.webp`, o bloco `screenshots` em `images.ts`, o link `#telas` do nav e o export `pwaCompatList` sem uso.
|
||||
- Exports mortos `dateFormatter` e `monthFormatter` de `features/transactions/formatting-helpers.ts`.
|
||||
|
||||
## [2.4.4] - 2026-04-27
|
||||
|
||||
Esta versão remove a dependência da extensão `pgcrypto` do PostgreSQL para a geração do `share_code` em pagadores. O default a nível de banco (`gen_random_bytes`) foi removido — agora a aplicação gera o código sempre via `crypto.randomBytes` do Node.js, num utilitário compartilhado. A consequência prática é que o setup inicial fica mais simples: não há mais script de habilitação de extensão, nem etapa extra no primeiro `db:push`, e bancos restaurados de dumps externos não precisam ter `pgcrypto` instalada. O script de backup também foi enxugado para gerar dumps focados nos schemas relevantes (`public` e `drizzle`), descartando os schemas internos do Supabase e eliminando os ~148 erros de restore em PostgreSQL padrão. Por fim, os logos da marca (ícone laranja e wordmark) foram vetorizados: as PNGs antigas foram substituídas por SVGs inline em componentes próprios e por arquivos `.svg` no `public/`, escalando perfeitamente em qualquer tamanho — inclusive nos PDFs exportados, que agora rasterizam o SVG em alta resolução.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Schema: coluna `share_code` em `pagadores` perdeu o default `substr(encode(gen_random_bytes(24), 'base64'), 1, 24)` — campo continua `NOT NULL` e a aplicação passa a fornecer o valor explicitamente em todas as inserções
|
||||
- Pagadores: nova função utilitária `generateShareCode()` em `src/shared/lib/payers/share-code.ts` (server-only) — usa `crypto.randomBytes(18).toString("base64url").slice(0, 24)`
|
||||
- Pagadores: `createPayerAction`, `ensureDefaultPagadorForUser`, `resetUserAppData` (settings) e `mock-data.ts` agora chamam `generateShareCode()` ao inserir um pagador
|
||||
- Backup: `scripts/backup.sh` agora dumpa apenas os schemas `public` e `drizzle` — schemas internos do Supabase (`auth`, `realtime`, `storage`, `vault`, `graphql`, `graphql_public`, `extensions`, `pgbouncer`) e suas extensions/roles deixam de poluir os dumps. Restaurações em PostgreSQL padrão passam a executar sem os ~148 erros de `role/extension does not exist`
|
||||
- Logo: `Logo` foi quebrado em três arquivos — `src/shared/components/logo.tsx` (orquestrador), `logo-icon.tsx` (ícone laranja em SVG inline, viewBox `0 0 200 200`) e `logo-text.tsx` (wordmark em SVG inline, viewBox `0 0 574.201 89.6`). API pública (`variant`, `invertTextOnDark`, `colorIcon`, `iconClassName`, `textClassName`) preservada
|
||||
- Assets: `public/images/logo_small.png` e `logo_text.png` substituídos por `logo_small.svg` e `logo_text.svg` (com `width`/`height` explícitos para compatibilidade com `<img>` em canvas)
|
||||
- Exports: `loadExportLogoDataUrl` agora carrega SVG e rasteriza no canvas a 4× a resolução natural antes de gerar o data URL — mantém nitidez quando o PDF amplia a imagem
|
||||
|
||||
### Removido
|
||||
|
||||
- Pasta `scripts/postgres/` (continha `init.sql` e `enable-extensions.ts`)
|
||||
- Script `pnpm db:extensions` no `package.json`
|
||||
- Referências ao `pnpm db:extensions` no README
|
||||
- `public/images/logo_small.png` e `public/images/logo_text.png` (substituídos pelos `.svg`)
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Migrations: conflito de numeração resolvido — `0027_fancy_reaper` renomeado para `0028_fancy_reaper` (o número 0027 já estava ocupado pelo arquivo órfão `0027_glorious_mindworm`); journal e snapshot atualizados
|
||||
- TS: removido `baseUrl` do `tsconfig.json` para evitar erro `TS5101` (deprecação no TS 7) — `moduleResolution: bundler` resolve os `paths` relativos ao próprio `tsconfig`, dispensando `baseUrl`
|
||||
|
||||
### Documentação
|
||||
|
||||
- README: seção Backup atualizada — arquivos gerados agora especificam que apenas os schemas `public` e `drizzle` são dumpados
|
||||
- README: seção Restore reescrita com o fluxo correto para banco Docker (`DROP SCHEMA public CASCADE` + `pg_restore --clean --if-exists --disable-triggers`)
|
||||
- README: comando rápido de Docker Compose de backup/restore substituído por `pnpm backup`
|
||||
- README: header passa a apontar para `logo_small.svg`
|
||||
|
||||
## [2.4.3] - 2026-04-25
|
||||
|
||||
Esta versão amplia o trabalho com lançamentos divididos: anexos passam a ser visíveis para pessoas com acesso compartilhado, a importação para conta própria copia os arquivos de forma independente e a edição ganha a opção de aplicar a alteração nos dois lados do par. Três caminhos de deleção foram corrigidos para não deixar arquivos órfãos no storage. Também traz refresh visual nos badges de tipo e radio buttons, prefetch server-side de logos para reduzir chamadas de API no dashboard, e ajustes pontuais no healthcheck do container e em rótulos da UI.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Schema: coluna `split_group_id` (uuid, nullable) em `lancamentos` com índice `(user_id, split_group_id)` — liga as shares do mesmo evento de divisão
|
||||
- Split: `buildLancamentoRecords` atribui um `splitGroupId` único por cycle (parcelado, recorrente ou único) para ambas as shares
|
||||
- Split: edição cooperativa via `updateTransactionSplitPairAction` — ao editar um lançamento dividido, novo dialog `SplitPairDialog` permite escolher entre aplicar somente neste lado ou nos dois lados (nome, data, categoria e demais campos compartilhados; valor e payer permanecem por share)
|
||||
- Importação: "Importar para Minha Conta" agora copia os anexos do lançamento-fonte para a conta de quem está importando (novo arquivo, novo `userId`, novo `fileKey` — cópia independente via S3 CopyObject). `createSchema` ganhou campo opcional `importFromTransactionId`; helper `copyAttachmentsForImport` valida acesso à fonte via ownership direto ou `payerShares`
|
||||
- Importação: dialog "Importar para Minha Conta" exibe seção read-only "Anexos que serão copiados" listando os anexos do lançamento-fonte antes da confirmação
|
||||
- Filtros: nova chave `isDivided` na tabela de lançamentos — toggle "Somente divididos" no drawer de filtros mantém o estado na URL
|
||||
- Performance: prefetch server-side de mapeamentos Logo.dev no `/dashboard`, `/transactions` e `/payers/[payerId]` — uma única query SQL em batch (`fetchEstablishmentLogoMap`) semeia o cache do React Query antes do primeiro render, eliminando os N requests para `/api/logo/mapping`
|
||||
|
||||
### Alterado
|
||||
|
||||
- Anexos: `fetchTransactionAttachments` e `fetchTransactionAttachmentsAction` passam a autorizar leitura por acesso à transação (direto ou via `payerShares`), permitindo que pessoas com pagador compartilhado visualizem anexos de lançamentos divididos
|
||||
- Anexos: upload (`confirmAttachmentUploadAction`) e detach em massa (`detachAttachmentBulkAction`) agora expandem `transactionIds` para incluir shares irmãs via `splitGroupId` — o vínculo em `transaction_attachments` é replicado para manter simetria
|
||||
- Anexos: delete/detach continuam restritos ao criador (sem alteração de escrita); dashboard (`fetchAttachmentsForPeriod`) permanece listando apenas os anexos do próprio usuário
|
||||
- Migração: lançamentos divididos criados antes desta versão ficam com `split_group_id` NULL e mantêm o comportamento antigo (anexos não visíveis para a contraparte); apenas splits novos são afetados
|
||||
- Storage: `deleteS3Object` passa a ignorar `NoSuchKey` silenciosamente — providers S3-compatíveis (ex.: Cloudflare R2) lançam esse erro ao deletar objeto inexistente, ao contrário do comportamento idempotente do S3 padrão
|
||||
- UI/Badges: `TransactionTypeBadge` redesenhado — substitui o `StatusDot` por ícones direcionais (`RiArrowRightDownLine` receita, `RiArrowRightUpLine` despesa, `RiArrowLeftRightLine` transferência), com borda visível, shadow sutil e variantes dark mode dessaturadas; rótulo "Transferência" abreviado para "Transf."
|
||||
- UI/Forms: indicador do `RadioGroup` trocado de círculo (`RiCircleLine`) por check (`RiCheckLine`) com fundo sólido `primary` no estado selecionado
|
||||
- UI/Antecipação: tabela de seleção de parcelas reduzida de quatro para três colunas (estabelecimento + fatura + valor) — informações de parcela e vencimento absorvidas pela coluna do estabelecimento
|
||||
- Tipografia: fonte Inter agora carrega explicitamente os pesos 500, 600 e 700 (antes derivava de 400)
|
||||
- Deps: better-auth 1.6.5 → 1.6.9, @aws-sdk/client-s3 3.1032 → 3.1037, @tanstack/react-query 5.99.2 → 5.100.3, @biomejs/biome 2.4.12 → 2.4.13, tailwindcss 4.2.2 → 4.2.4, resend 6.12.0 → 6.12.2
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Anexos: deleção em massa por série (`deleteTransactionBulkAction`) não chamava cleanup de storage — arquivos ficavam órfãos no S3 após apagar "este e futuros" ou "todos" de uma série parcelada/recorrente com anexo
|
||||
- Anexos: deleção múltipla por seleção (`deleteMultipleTransactionsAction`) não chamava cleanup de storage — mesmo problema ao selecionar vários lançamentos com anexo e deletar em lote
|
||||
- Anexos: reset de conta em Ajustes (`resetUserAppData`) não limpava o storage — todos os arquivos do usuário ficavam órfãos no S3 após a operação de zeragem
|
||||
- Página da pessoa (`/payers/[payerId]`): `fetchPagadorLancamentos` agora calcula `hasAttachments` via `EXISTS`, fazendo o ícone de clipe aparecer na tabela de lançamentos (antes só aparecia em `/transactions`)
|
||||
- Categorias: mensagem de sucesso ao atualizar exibia "Category atualizada com sucesso." — corrigido para "Categoria atualizada com sucesso."
|
||||
- Antecipação: rótulos "Category" e "Período" no dialog corrigidos para "Categoria" e "Fatura"
|
||||
- Docker: healthcheck do container `app` agora usa `127.0.0.1:3000` em vez de `localhost:3000`, evitando connection timeout em hosts com IPv6 (resolvendo [#44](https://github.com/felipegcoutinho/openmonetis/issues/44))
|
||||
|
||||
## [2.4.2] - 2026-04-20
|
||||
|
||||
Esta versão é quase toda sobre organização e polimento. O código interno do Dashboard foi reestruturado — módulos espalhados pela raiz da feature foram agrupados em subdiretórios coesos e a arquitetura de widgets foi renovada com um novo `widget-registry`. A sidebar lateral foi aposentada em favor de uma navegação concentrada na navbar. A interface passou por um refinamento visual amplo: cards redesenhados, dark mode mais consistente e efeitos decorativos removidos para uma composição mais limpa. As imagens de preview da landing page foram atualizadas. Por fim, a integração com Logo.dev ganhou uma arquitetura mais segura — o token agora é lido apenas no servidor e nunca chega ao cliente. O conceito de "Pagador" foi renomeado para "Pessoa" em toda a interface.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Dashboard: nova arquitetura de widgets com `widget-registry` — módulos reorganizados em subdiretórios (`bills/`, `invoices/`, `notes/`, `notifications/`, `overview/`, `payments/`, `goals-progress/`, `categories/`)
|
||||
- Dashboard: novos componentes `category-breakdown-chart`, `category-breakdown-list`, `goals-progress-item` e `percentage-change-indicator`
|
||||
- Logo.dev: `server.ts` com `isLogoDevEnabled()` e `buildLogoDevUrl()` server-side; `LogoDevProvider` propaga flag `enabled` para Client Components
|
||||
- Scripts: `mockup` adicionado ao `package.json` (`tsx scripts/mock-data.ts`)
|
||||
|
||||
### Alterado
|
||||
|
||||
- Nav: sidebar lateral removida — navegação unificada na navbar
|
||||
- UI/Tema: raio de borda global 0.625rem → 0.7rem; ajustes finos em `--card` e `--border` (light e dark)
|
||||
- UI: `DotPattern` removido do layout dashboard, tela de autenticação e landing page
|
||||
- UI: account-card redesenhado com cores de saldo (success/destructive) e tooltip para flags de exclusão
|
||||
- UI: budget-card, card-item e componentes do calendário (day-cell, event-modal) com layout revisado
|
||||
- UI: auth-card-shell simplificado (removido glassmorphism e blob animado)
|
||||
- Landing: imagens de preview atualizadas; `mainFeatures` + `extraFeatures` unificados em grid único; dark mode nos botões de CTA
|
||||
- Navbar: dark mode corrigido no navbar-shell (`dark:bg-card`, `dark:border-b-border`)
|
||||
- Logo.dev: `NEXT_PUBLIC_LOGO_DEV_TOKEN` renomeado para `LOGO_DEV_TOKEN` (agora lido em runtime server-side apenas)
|
||||
- UI: conceito "Pagador/Pagadores" renomeado para **"Pessoa/Pessoas"** em toda a interface — labels, títulos, toasts, mensagens de erro, cabeçalhos de tabela e exportações. Código, rotas (`/payers`) e schema do banco (`pagadores`) permanecem inalterados; a divergência entre UI e código é intencional
|
||||
- Deps: next 16.2.3 → 16.2.4, better-auth 1.6.2 → 1.6.5, ai 6.0.159 → 6.0.168 e outros patches menores
|
||||
- Notas/Tarefas: ícone de tarefa concluída em visualização (card e detalhes) simplificado para `RiCheckLine` verde sem caixa; checkbox no modal de edição usa fundo e borda `success` com ícone `success-foreground` (claro no light, escuro no dark)
|
||||
- Notas/Detalhes: botões do footer reordenados ("Cancelar" à esquerda, "Alterar" primário à direita)
|
||||
|
||||
### Removido
|
||||
|
||||
- Nav: componentes sidebar (`app-sidebar`, `nav-main`, `nav-secondary`, `nav-user`, `nav-link`), `sidebar.tsx` e `use-mobile.ts`
|
||||
- Dashboard: ~25 widgets monolíticos obsoletos (`inbox-widget`, `bills-widget`, `notes-widget`, `payers-widget`, `my-accounts-widget` etc.)
|
||||
- Dashboard: arquivos dispersos na raiz da feature movidos para subdiretórios (arquivos antigos removidos)
|
||||
- CSS: variáveis `--data-7` a `--data-10` removidas do tema
|
||||
- CI: build arg `NEXT_PUBLIC_LOGO_DEV_TOKEN` removido do `Dockerfile` e do workflow `docker-publish.yml` — basta configurar `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` como variáveis de runtime no host (Coolify, Railway, etc.)
|
||||
|
||||
## [2.4.1] - 2026-04-16
|
||||
|
||||
Versão pequena com refresh visual nas telas de autenticação (efeito blob com três círculos coloridos em movimento e card com glassmorphism), capitalização dos labels da navbar para melhor legibilidade e otimização do banco com 17 índices novos em foreign keys — evitando sequential scans em deletes em tabelas grandes como `lancamentos`. Corrigida regressão no `postgres:18-alpine` que recusava iniciar em instalações existentes; adicionada variável `PGDATA` no compose para preservar dados de quem já tinha o volume populado.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42)
|
||||
- DB: 17 índices em foreign keys — evita sequential scans em deletes nas tabelas pai. Impacto maior nas FKs de `lancamentos` (conta_id, categoria_id, antecipacao_id), onde deletes em `categorias` antes provocavam full scan na tabela de lançamentos
|
||||
|
||||
### Alterado
|
||||
|
||||
- UI/Navbar: labels capitalizados (Lançamentos, Categorias, Contas) em vez de caixa baixa — melhora legibilidade (PR #42)
|
||||
|
||||
### Removido
|
||||
|
||||
- DB: 7 índices sem uso — `tokens_api_user_id_idx`, `cartoes_user_id_status_idx`, `contas_user_id_status_idx`, `pagadores_user_id_status_idx`, `pagadores_user_id_role_idx`, `dashboard_notification_states_user_id_archived_idx`, `antecipacoes_parcelas_series_id_idx` (0 scans em 187 dias de estatísticas)
|
||||
- UI/Settings: tab de Integrações órfã removida (não tinha `TabsContent` correspondente)
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Docker: container do PostgreSQL falhava ao iniciar em instalações existentes após atualização da imagem `postgres:18-alpine` — entrypoint passou a recusar dados no caminho legado `/var/lib/postgresql/data`. Adicionada variável `PGDATA` no `docker-compose.yml` para fixar o caminho e preservar dados de quem já tinha o volume populado (resolve #41)
|
||||
|
||||
## [2.4.0] - 2026-04-13
|
||||
|
||||
Esta versão integra o serviço Logo.dev para exibir automaticamente logos de marcas na coluna de estabelecimentos dos lançamentos, com picker manual para fixar o domínio quando a sugestão automática não acerta. As consultas vão por novas rotas de API (`/api/logo/search` e `/api/logo/mapping`) que servem como proxy seguro — a secret key fica server-side. Inclui também tabela própria `establishment_logos` com PK composta `(user_id, name_key)` para persistir as preferências por usuário.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
|
||||
- Estabelecimentos: picker de logo por estabelecimento — clique no avatar para buscar e fixar um domínio Logo.dev específico (salvo por usuário no banco)
|
||||
- API: rotas `/api/logo/search` e `/api/logo/mapping` — proxy seguro para Logo.dev Brand Search API (secret key server-side) e consulta de mapeamentos salvos
|
||||
- Schema: tabela `establishment_logos` com PK composta `(user_id, name_key)` para persistir preferências de logo por usuário
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Dev: `.env.example` usava host `db` no `DATABASE_URL`, causando erro `EAI_AGAIN` ao rodar `pnpm dev` localmente — corrigido para `localhost`
|
||||
|
||||
### Documentação
|
||||
|
||||
- README: tabela comparativa entre Perfil 1 (Usar) e Perfil 2 (Desenvolver) com diferenças de setup, `DATABASE_URL` e instruções de atualização
|
||||
- README: seção "Variáveis de Ambiente" esclarecida — distingue contexto Docker (Perfil 1) de desenvolvimento local (Perfil 2)
|
||||
- Logo.dev: crie uma conta em logo.dev para obter as chaves `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` — plano gratuito inclui 500.000 requisições/mês
|
||||
|
||||
## [2.3.8] - 2026-04-12
|
||||
|
||||
Refatoração do `docker-compose.yml` para virar standalone — agora basta um `curl` + `docker compose up -d`, sem dependências de arquivos externos ou profiles complexos. README reescrito em dois perfis claros (Usar com Docker e Desenvolver com hot-reload) e scripts npm reduzidos de 10 para 5.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
|
||||
- Docker: `docker-entrypoint.sh` simplificado — extensão `pgcrypto` criada via Node.js antes das migrations; loop de retry reescrito; removido hack `@localhost → @db`
|
||||
- Docker: scripts reduzidos de 10 para 5 — `docker:up`, `docker:db`, `docker:down`, `docker:logs`, `docker:update`
|
||||
- Docs: README reestruturado em dois perfis claros — **Usar** (só Docker) e **Desenvolver** (hot-reload)
|
||||
|
||||
## [2.3.7] - 2026-04-11
|
||||
|
||||
Esta versão amplia significativamente o dashboard com três novos widgets configuráveis (Anexos, Inbox, Tendências de Categoria), adiciona filtros úteis na tabela de lançamentos (por status de pagamento e por presença de anexo) e moderniza a tipografia substituindo a fonte local por Inter (Google Fonts, self-hosted pelo Next.js) — eliminando arquivos `.woff2` do repositório. Pesos tipográficos foram padronizados para `font-semibold` em títulos, rótulos e valores monetários, e o card de grupo de parcelas foi redesenhado expandindo num dialog de detalhes com parcelas pagas/pendentes separadas. No backend, a CSP foi expandida para permitir preview de anexos PDF via S3, e o setup ganhou script `install-deps.sh` pra preparar servidores Ubuntu 24.04 limpos.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria
|
||||
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
|
||||
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
|
||||
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
|
||||
- Docker: variáveis `PUBLIC_DOMAIN`, `UMAMI_URL`, `UMAMI_WEBSITE_ID` e `UMAMI_DOMAINS` passadas ao container da aplicação no `docker-compose.yml`
|
||||
|
||||
### Alterado
|
||||
|
||||
- Fonte: substituída fonte local `America` por `Inter` (Google Fonts, self-hosted pelo Next.js) — elimina arquivos `.woff2` do repositório
|
||||
- Tipografia: peso tipográfico padronizado de `font-medium` para `font-semibold` em títulos, rótulos e valores monetários em toda a interface
|
||||
- Parcelas: redesenho do card de grupo de parcelas — expandindo para dialog de detalhes com parcelas pagas/pendentes separadas
|
||||
- Inbox: redesenho do card de pré-lançamento — logo maior, hierarquia tipográfica melhorada
|
||||
- Lançamentos: filtros de tipo, condição e forma de pagamento agora usam slugs em URL (ex: `receita` em vez do valor literal com acentos)
|
||||
- Estabelecimento: popover de autocomplete agora respeita a largura do input ao abrir
|
||||
- CSP: adicionado `frame-src` para permitir preview de anexos PDF via S3
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Docker: corrigido crash loop no container com mensagem `exec /app/docker-entrypoint.sh: no such file or directory` causado por CRLF no `docker-entrypoint.sh` em ambientes Windows/WSL2 — adicionado `sed -i 's/\r$//'` no Dockerfile e `.gitattributes` com `eol=lf` para scripts shell
|
||||
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
|
||||
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
|
||||
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
|
||||
- Scripts: `install-deps.sh` — spinner travava o script por `wait` retornar código não-zero com `set -e` ativo; corrigido com `|| true`
|
||||
- Scripts: `install-deps.sh` — prompt interativo do corepack suprimido com `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
|
||||
- Scripts: `install-deps.sh` — PATH do Homebrew não estava configurado na seção de resumo
|
||||
|
||||
### Removido
|
||||
|
||||
- Scripts: removidos arquivos órfãos `scripts/dev.ts` e `scripts/setup-env.sh` (substituídos pelo `setup.mjs`)
|
||||
- Docker: `docker-compose.yml` agora funciona sem arquivo `.env` — `DATABASE_URL` tem valor padrão com credenciais de desenvolvimento
|
||||
- Docker: `docker-entrypoint.sh` converte automaticamente `@localhost:` para `@db:` na `DATABASE_URL` ao iniciar o container, eliminando a necessidade de usar hosts diferentes no `.env` para desenvolvimento local e Docker
|
||||
|
||||
## [2.3.6] - 2026-04-09
|
||||
|
||||
Correção pontual no Docker — adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para o `drizzle-kit` resolver corretamente o `drizzle-orm` ao executar as migrations no container.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
|
||||
|
||||
## [2.3.5] - 2026-04-07
|
||||
|
||||
Correção crítica na CSP: regra movida do `next.config.ts` (build time) para `proxy.ts` (runtime), desbloqueando uploads de anexos quando o `S3_ENDPOINT` ainda não estava disponível durante o build da imagem Docker.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
|
||||
|
||||
## [2.3.4] - 2026-04-05
|
||||
|
||||
Correção pontual no upload de anexos — a CSP `connect-src` bloqueava o fetch para o storage, gerando `NetworkError` na hora de subir o arquivo.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
|
||||
|
||||
## [2.3.3] - 2026-04-05
|
||||
|
||||
Correção do fluxo de tokens da API: `/api/auth/device/verify` voltou a aceitar tokens criados pela tela de Settings (revertido de JWT para hash lookup). O prefixo dos tokens também foi renomeado de `os_` para `opm_` (OpenMonetis) e rotas JWT não utilizadas foram removidas — usuários precisam recriar os tokens existentes.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
|
||||
|
||||
### Alterado
|
||||
|
||||
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
|
||||
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
|
||||
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
|
||||
|
||||
## [2.3.2] - 2026-04-04
|
||||
|
||||
Esta versão concentra hardening de segurança. Tokens da API ganharam expiração obrigatória de 1 ano (sem mais tokens eternos) e o refresh foi corrigido para validar JWT por assinatura. A CSP foi expandida com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src` (no lugar de uma regra única ampla), e foi adicionada mitigação para CVE-2024-44294 desabilitando parsing de fórmulas em `xlsx`. Inclui ainda novos headers (`Referrer-Policy`, `X-Permitted-Cross-Domain-Policies`), respostas `401 JSON` em vez de redirect 302 em rotas autenticadas, `security.txt` (RFC 9116) e correção de URL com protocolo duplicado no sitemap.
|
||||
|
||||
### Segurança
|
||||
|
||||
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
|
||||
- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura
|
||||
- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294
|
||||
- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src`
|
||||
- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies`
|
||||
- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados
|
||||
- Health: removido campo `version` da resposta do `/api/health`
|
||||
- robots.txt: simplificado para não expor mapa de rotas internas
|
||||
- Sitemap: corrigida URL com protocolo duplicado (`https://https://`)
|
||||
- Criado `security.txt` (RFC 9116)
|
||||
|
||||
## [2.3.1] - 2026-04-03
|
||||
|
||||
Correção pontual de infraestrutura — dependências do `drizzle-kit` passaram a ser instaladas em `/app/migrate/` separadamente do `node_modules` do build standalone, corrigindo o erro `Cannot find module 'next'` no startup do container.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
|
||||
|
||||
## [2.3.0] - 2026-04-03
|
||||
|
||||
Esta versão introduz `@tanstack/react-query` no projeto, padronizando cache, deduplicação e invalidação de leituras client-side. Várias features (anexos, insights, antecipação de parcelas) passaram a usar React Query no lugar de `useEffect` manual sobre rotas GET dedicadas. O dashboard ganhou ajuda contextual em cada métrica e configuração persistida pra ocultar contas marcadas como não consideradas no saldo total; o menu do usuário na navbar passou a avisar quando há release nova publicada no GitHub; e o Docker passou a rodar migrations automaticamente no startup via `docker-entrypoint.sh`. Internamente, o `knip` foi adicionado pra auditar arquivos/exports/tipos sem uso, várias rotas e actions ganharam validações extras (filtros por `userId` em joins, rate limits explícitos no Better Auth, headers `Cache-Control: private, no-store` em rotas privadas) e o projeto foi atualizado para Next.js 16.2.2 e Biome 2.4.10.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side
|
||||
- Dashboard: widget "Minhas Contas" ganha preferência persistida para mostrar ou ocultar contas marcadas como não consideradas no saldo total
|
||||
- Dashboard: cards de métricas ganham botão de ajuda com explicação do cálculo exibido no app
|
||||
- Versionamento: menu do usuário na navbar passa a avisar quando existe release mais recente publicada no GitHub
|
||||
- Qualidade: adiciona `knip` ao projeto com o script `pnpm run lint:deadcode` para auditar arquivos, exports e tipos sem uso
|
||||
- Infraestrutura: imagem Docker passa a rodar migrations automaticamente via `docker-entrypoint.sh` antes de iniciar a aplicação
|
||||
|
||||
### Alterado
|
||||
|
||||
- Anexos: listagem no modal de edição/detalhes, URLs temporárias da galeria e preview deixam de depender de `useEffect` para data fetching direto no componente e passam a usar React Query sobre rotas GET dedicadas
|
||||
- Insights: carregamento de análises salvas passa a usar React Query com cache por período, mantendo estado draft local apenas para análises recém-geradas ou removidas
|
||||
- Parcelamentos: histórico de antecipações no diálogo passa a usar React Query com invalidação automática após cancelamento
|
||||
- Dashboard, insights e relatórios passam a excluir movimentações de contas marcadas como não consideradas no saldo total; balanço e previsto também passam a considerar ajustes de transferências entre contas consideradas e não consideradas
|
||||
- UX: boletos e faturas passam a exibir labels relativas como "vence hoje", "vence amanhã" e "pago ontem", com tooltip para a data completa
|
||||
- Lançamentos: diálogo foi reorganizado em blocos mais claros; a criação passa a aceitar múltiplos anexos e a edição em lote preserva `purchaseDate` e `period` ao propagar alterações por série
|
||||
- Inbox e tabela de lançamentos foram componentizados em partes menores, mantendo paginação e ações em lote mais simples de evoluir
|
||||
- Infraestrutura: workflow de publish ganha etapa obrigatória de qualidade; `docker-compose` passa a suportar perfil local ou banco remoto; build fixa `pnpm@10.33.0`; projeto atualizado para `Next.js 16.2.2`, `Biome 2.4.10` e dependências correlatas
|
||||
- Qualidade: `knip` ganha configuração inicial para reduzir falsos positivos, ignorando `src/shared/components/ui/**`, o worker público de PDF, `setup.mjs` e o falso positivo de `postcss`
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Segurança: criação de antecipações agora valida se `payerId` e `categoryId` informados pertencem ao usuário autenticado antes de persistir referências cruzadas
|
||||
- Segurança: histórico de antecipações endurece os joins de `transactions`, `payers` e `categories` com filtro por `userId`, evitando exposição de nomes relacionados caso exista referência inconsistente no banco
|
||||
- Segurança: domínio público deixa de responder rotas `/api/*`, e o Better Auth passa a aplicar rate limits explícitos para login e cadastro por e-mail
|
||||
- APIs privadas: rotas de anexos, insights salvos, histórico de antecipações e presign de download passam a responder com `Cache-Control: private, no-store`; a rota de antecipações também deixa de devolver mensagens internas de erro ao cliente
|
||||
- Build: rotas web de tokens do Companion passam a ser explicitamente dinâmicas, removendo o warning de prerender no `next build`
|
||||
- Lançamentos: edição em série de compras parceladas volta a persistir `purchaseDate` e `period`, permitindo mover parcelas para a fatura ou competência correta conforme o escopo escolhido
|
||||
- Lançamentos: edições que tentam mover compras de cartão para faturas já pagas agora são bloqueadas com mensagem clara também no fluxo de atualização e propagação em lote
|
||||
- Imagens: logos institucionais, avatares padrão e componentes com `next/image` em modo `fill` passam a usar containers fixos com `sizes`, removendo avisos de proporção e performance
|
||||
- Gráficos: `ChartContainer` passa a definir `initialDimension` no `ResponsiveContainer` do Recharts, evitando avisos `width(-1)` e `height(-1)` durante a medição inicial em widgets e relatórios
|
||||
|
||||
## [2.2.1] - 2026-04-01
|
||||
|
||||
Correção pontual no build da imagem Docker — removido `chown -R /app` do stage final (que travava o build/push da GitHub Action por lentidão excessiva); permissões agora definidas via `COPY --chown` direto.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
|
||||
|
||||
## [2.2.0] - 2026-04-01
|
||||
|
||||
Esta versão entrega uma nova página dedicada de galeria de anexos em `/attachments` com miniaturas, visualização inline (incluindo PDF via `pdfjs-dist`), download direto e acesso a partir do lançamento. As páginas de login e cadastro foram redesenhadas com sidebar mockup de faturas, três blocos de funcionalidade e gradiente decorativo. O dashboard passou a notificar boletos e faturas com vencimento dentro de 5 dias, e o cache do dashboard migrou de `unstable_cache` para a diretiva `use cache` (com `cacheTag` e `cacheLife`), com `cacheComponents: true` no `next.config.ts` e `connection()` em todas as páginas para forçar render dinâmico. A tipografia ganhou peso 500 (Medium) padronizado em títulos, valores e rótulos.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Anexos: nova página de galeria em `/attachments` com miniaturas, visualização inline de imagem e PDF, download direto e acesso a partir do lançamento
|
||||
- Anexos: suporte a visualização de PDF diretamente no app via `pdfjs-dist`
|
||||
- Autenticação: sidebar redesenhado com mockup de faturas e três itens de funcionalidade; páginas de login e cadastro ganham gradiente decorativo e logo visível no mobile
|
||||
- Notificações: alertas de vencimento para boletos e faturas do período seguinte exibidos quando o vencimento está dentro de 5 dias
|
||||
- Documentação: novo arquivo público `public/llms.txt` com resumo do projeto e links curados para documentação, setup e arquitetura
|
||||
|
||||
### Alterado
|
||||
|
||||
- Performance: queries de cache do dashboard migradas de `unstable_cache` para a diretiva `use cache` com `cacheTag` e `cacheLife`; todas as páginas do dashboard passam a chamar `connection()` para renderização dinâmica; `next.config.ts` adota `cacheComponents: true`
|
||||
- Tipografia: adicionada fonte America Medium (weight 500); pesos tipográficos padronizados para `font-medium` em títulos, valores e rótulos em todos os componentes
|
||||
- Anexos: `AttachmentPreview` foi simplificado para exibir apenas nome da transação, nome do arquivo, navegação entre anexos e ações de download, abrir em nova aba e fechar com ícone `X`
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries
|
||||
|
||||
## [2.1.2] - 2026-03-30
|
||||
|
||||
Pequena versão de polimento: novo escopo `"period"` na ação em lote de lançamentos (aplica alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um), preferência de tamanho máximo por arquivo de anexo (5/10/25/50/100 MB) persistida no banco e respeitada em todos os pontos de upload, e redesign visual da página de Configurações com separadores entre seções e títulos maiores.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
|
||||
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
|
||||
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
|
||||
|
||||
### Alterado
|
||||
|
||||
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
|
||||
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
|
||||
|
||||
## [2.1.1] - 2026-03-29
|
||||
|
||||
Esta versão extrai a navbar pra um componente `NavbarShell` compartilhado entre app e landing page e cria uma variante `navbar` no Button pra centralizar os estilos antes duplicados em `nav-styles.ts`. A integração com `@vercel/analytics`/`@vercel/speed-insights` foi substituída por Umami self-hosted via script tag no layout raiz.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
|
||||
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
|
||||
- Analytics: integração com Umami self-hosted via script tag no layout raiz
|
||||
|
||||
### Alterado
|
||||
|
||||
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
|
||||
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
|
||||
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
|
||||
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
|
||||
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
|
||||
|
||||
### Removido
|
||||
|
||||
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
|
||||
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
|
||||
|
||||
## [2.1.0] - 2026-03-28
|
||||
|
||||
Esta versão adiciona suporte a anexos em transações, com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento. O upload exige token assinado por arquivo, valida ownership da transação na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco. Inclui também novo workflow `release.yml` que cria tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Lançamentos: suporte a anexos em transações com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento
|
||||
- Infraestrutura: novo workflow `.github/workflows/release.yml` para criar tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`
|
||||
|
||||
### Alterado
|
||||
|
||||
- Anexos: upload agora exige token assinado por arquivo, valida propriedade da transação também na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: criação de transações no cartão de crédito agora bloqueia períodos cujas faturas já estão pagas, evitando divergência no relatório de análise de parcelas
|
||||
|
||||
## [2.0.3] - 2026-03-26
|
||||
|
||||
Correção pontual em `/transactions` — removida dependência de `crypto.randomUUID()` no carregamento inicial, que falhava em ambientes self-hosted sem HTTPS (a API só está disponível em contextos seguros).
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: `/transactions` deixa de depender de `crypto.randomUUID()` no carregamento inicial, corrigindo a falha em ambientes self-hosted sem HTTPS ao abrir a página
|
||||
|
||||
## [2.0.2] - 2026-03-25
|
||||
|
||||
Versão focada nas notificações da navbar: novo estado persistido permite marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário; o snapshot global passa a usar o período corrente do negócio (não mais o `periodo` da URL), itens lidos saem do badge e arquivados somem da lista padrão do sino. O filtro foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`. Inclui ajustes pontuais no detalhamento por categoria do dashboard (oculta categorias sem movimentação no período), na arte decorativa do cabeçalho de boas-vindas e na edição em lote de lançamentos em série (que agora propaga também o status de pagamento para transações fora do cartão).
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts`
|
||||
- Navbar: novo estado persistido para notificações do sino, permitindo marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário
|
||||
|
||||
### Alterado
|
||||
|
||||
- Navbar: o snapshot global de notificações deixa de depender do `periodo` da URL atual e passa a usar o período corrente do negócio; itens lidos saem do badge e itens arquivados somem da lista padrão do sino
|
||||
- Navbar: dropdown de notificações agora permite mostrar itens arquivados e reverter ações de leitura e arquivamento diretamente em cada item
|
||||
- Navbar: filtro da lista de notificações no sino foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`, com destaque visual mais forte para a aba ativa
|
||||
- Navbar: componente `notification-bell` foi desmembrado em hook e componentes locais menores, reduzindo acoplamento e facilitando manutenção
|
||||
- Dashboard: detalhamento por categoria agora oculta categorias sem movimentação no período, reduzindo ruído visual no card
|
||||
- UI: arte decorativa do topo da dashboard foi restrita à faixa do cabeçalho de boas-vindas, evitando que o `dot pattern` e o gradiente claro alterem a leitura visual do month picker
|
||||
- Lançamentos em série: a edição em lote agora também permite propagar o status de pagamento (`isSettled`) para transações não feitas no cartão de crédito
|
||||
- Seed de conta vazia: `scripts/mock-data.ts` agora processa `--help` antes de exigir `DATABASE_URL` e só cria categorias/pagador admin depois de validar que a conta está financeiramente vazia
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Navbar: ao desarquivar a última notificação no modo de arquivadas, o dropdown volta automaticamente para a listagem padrão e o toggle deixa de ficar travado
|
||||
- Filtros financeiros: transações de conta com observação nula, como compras parceladas no Pix, deixam de ser ocultadas indevidamente em `/transactions`, dashboard e relatórios quando a conta está configurada para desconsiderar o saldo inicial
|
||||
- Backup: geração do arquivo `*.data.sql.gz` volta a usar a saída correta do `pg_restore`
|
||||
|
||||
### Removido
|
||||
|
||||
- DB: colunas `system_font` e `money_font` da tabela `preferencias_usuario`, que não são mais utilizadas no código
|
||||
|
||||
## [2.0.1] - 2026-03-21
|
||||
|
||||
Versão de correções na inbox de pré-lançamentos: filtro por app passa a montar a lista completa a partir de todos os itens do status atual (sem depender da página carregada), notificações de cartões/apps sem logo cadastrado passam a usar `default_icon.png` como fallback, e o select de apps exibe os logos. Inclui também correção de divergência entre a versão exibida no UI e a reportada pelo `/api/health` (que agora reporta a versão atual do `package.json`).
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Inbox: filtro por app em `/inbox` agora monta a lista completa de apps da aba a partir de todos os itens do status atual, sem depender apenas da página carregada, e o SSR deixa de quebrar quando `sourceApps` vier inconsistente
|
||||
- Inbox: notificações de cartões/apps sem logo cadastrado agora exibem `default_icon.png` como fallback visual nos cards
|
||||
- Inbox: select de apps em `/inbox` agora exibe os logos dos apps/cartões, com fallback para `default_icon.png` quando não houver logo mapeado
|
||||
- Inbox: cabeçalhos de data entre grupos de cards agora exibem ícone e tipografia um pouco maior para melhorar a leitura
|
||||
- Versionamento: `/api/health` passa a reportar a versão atual do `package.json`, evitando divergência entre healthcheck, UI e release publicada
|
||||
|
||||
## [2.0.0] - 2026-03-21
|
||||
|
||||
Marco importante do projeto. Esta versão consolida ganhos de performance, segurança e organização interna. No backend, paginação server-side real foi implementada em transações, extrato e inbox; o dashboard reduziu de 19 fetchers para 7 blocos com agregações compartilhadas; exportações de PDF/Excel passaram a carregar libs sob demanda apenas no clique; e o cache de dashboard/insights ganhou invalidação segmentada por `userId` (sem fallback global). Internamente, identificadores foram migrados de PT-BR para inglês (`lancamento` → `transaction`, `pagador` → `payer`, `conta` → `account`, etc.) e helpers foram consolidados em módulos de domínio. Visualmente, a navbar e os cards de auth ganharam dot pattern + brilho em primary, faturas tiveram refinamento na hierarquia visual, e a tipografia foi unificada na família America. Inclui ainda script `scripts/backup.sh` para backup automático do PostgreSQL, importação de extratos OFX e XLS/XLSX com tela de revisão e dedup por FITID, e nova opção de zerar dados financeiros sem excluir o usuário.
|
||||
|
||||
### 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
|
||||
|
||||
Versão de organização interna sem mudanças visíveis grandes. Períodos e navegação mensal passaram a usar os helpers centrais de período (`YYYY-MM`), hooks locais (calculadora, month-picker, logo picker) foram movidos pra perto das respectivas features e `components/navbar`/`sidebar` foram consolidados em `components/navigation/*`. Análise de parcelas migrou para `/relatorios/analise-parcelas`, exportações em PDF/CSV/Excel ganharam melhor branding e apresentação, e a calculadora teve ajustes de estabilidade no arrasto.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Períodos e navegação mensal: `useMonthPeriod` passou a usar os helpers centrais de período (`YYYY-MM`), o month-picker foi simplificado e o rótulo visual agora segue o formato `Março 2026`.
|
||||
@@ -23,6 +642,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.7.6] - 2026-03-02
|
||||
|
||||
Esta versão adiciona suporte completo a Passkeys (WebAuthn) via `@better-auth/passkey`: nova aba em `/ajustes` permite listar, adicionar, renomear e remover credenciais, e a tela de login ganhou ação dedicada para passkey. O dashboard ganhou widget de Anotações e atalhos rápidos na toolbar de widgets pra criar Receita, Despesa ou Anotação direto. Top Estabelecimentos foi unificado num único widget com abas, e o widget "Lançamentos recentes" foi substituído por "Progresso de metas" com lista de orçamentos do período (gasto, limite e percentual de uso por categoria).
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Suporte completo a Passkeys (WebAuthn) com plugin `@better-auth/passkey` no servidor e `passkeyClient` no cliente de autenticação
|
||||
@@ -57,6 +678,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.7.5] - 2026-02-28
|
||||
|
||||
Versão pequena de polimento: ações para excluir item individual (processado/descartado) e limpar itens em lote por status na inbox de pré-lançamentos, redesign dos cards e diálogos dos widgets de boletos e faturas com indicação "Atrasado / Pagar" quando vencidos e não pagos, e migração da página de categorias de cards pra layout em tabela com link direto para detalhe e ações inline.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Inbox de pré-lançamentos: ações para excluir item individual (processado/descartado) e limpar itens em lote por status
|
||||
@@ -75,6 +698,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.7.4] - 2026-02-28
|
||||
|
||||
Versão de polimento de responsividade no mobile: 26 componentes ajustados (navbar, filtros, skeletons, widgets, dialogs), card de análise de parcelas empilhado verticalmente em telas pequenas e cards do top estabelecimentos reorganizados em coluna única no mobile. Inclui também regra mais inteligente em "Remover selecionados" — quando todos os itens pertencem à mesma série, abre dialog de escopo com 3 opções; e ajuste no consumo de limite por despesa recorrente no cartão (só consome quando a data já passou).
|
||||
|
||||
### Alterado
|
||||
|
||||
- Card de análise de parcelas (`/dashboard/analise-parcelas`): layout empilhado no mobile — nome/cartão e valores Total/Pendente em linhas separadas ao invés de lado-a-lado, evitando truncamento
|
||||
@@ -86,6 +711,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.7.3] - 2026-02-27
|
||||
|
||||
Versão pequena com nova prop `compact` no DatePicker (formato abreviado "28 fev", sem "de" e sem ano) e modal de múltiplos lançamentos reformulado: selects de conta e cartão separados por forma de pagamento, InlinePeriodPicker ao escolher cartão de crédito e DatePicker compacto.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Prop `compact` no DatePicker para formato abreviado "28 fev" (sem "de" e sem ano)
|
||||
@@ -97,6 +724,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.7.2] - 2026-02-26
|
||||
|
||||
Versão de polimento dos diálogos: padding maior (p-10), largura padronizada em `max-w-xl` e botões do footer com largura igual; o lançamento dialog ganhou seção colapsável "Condições e anotações" e cálculo automático do período da fatura via `deriveCreditCardPeriod()`. Inclui também uma faxina de tipos (non-null assertions removidas, `any` substituído por tipos explícitos em 15+ arquivos) e remoção de 6 componentes e 20+ funções/tipos sem uso.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Dialogs padronizados: padding maior (p-10), largura max-w-xl, botões do footer com largura igual (flex-1)
|
||||
@@ -120,6 +749,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.7.1] - 2026-02-24
|
||||
|
||||
Esta versão substitui o header lateral por uma topbar de navegação com backdrop blur e links agrupados em 5 seções (Dashboard, Lançamentos, Cartões, Relatórios, Ferramentas), expande o sino de notificações pra exibir orçamentos estourados e pré-lançamentos pendentes em seções separadas, e cria página dedicada de changelog em `/changelog` (acessível pelo menu do usuário com a versão atual exibida ao lado).
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Topbar de navegação substituindo o header fixo: backdrop blur, links agrupados em 5 seções (Dashboard, Lançamentos, Cartões, Relatórios, Ferramentas)
|
||||
@@ -143,6 +774,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.6.3] - 2026-02-19
|
||||
|
||||
Correção pontual: variável `RESEND_FROM_EMAIL` não era lida corretamente do `.env` quando o valor continha espaços (precisa estar entre aspas). Leitura centralizada em `lib/email/resend.ts` com `getResendFromEmail()` e carregamento explícito do `.env` no contexto de Server Actions.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- E-mail Resend: variável `RESEND_FROM_EMAIL` não era lida do `.env` (valores com espaço precisam estar entre aspas). Leitura centralizada em `lib/email/resend.ts` com `getResendFromEmail()` e carregamento explícito do `.env` no contexto de Server Actions
|
||||
@@ -154,12 +787,16 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.6.2] - 2026-02-19
|
||||
|
||||
Correção pontual no mobile: ao selecionar um logo no diálogo de criação de conta/cartão, o diálogo principal fechava inesperadamente. Adicionado `stopPropagation` nos eventos de click/touch dos botões e delay com `requestAnimationFrame` antes de fechar o seletor.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Bug no mobile onde, ao selecionar um logo no diálogo de criação de conta/cartão, o diálogo principal fechava inesperadamente: adicionado `stopPropagation` nos eventos de click/touch dos botões de logo e delay com `requestAnimationFrame` antes de fechar o seletor de logo
|
||||
|
||||
## [1.6.1] - 2026-02-18
|
||||
|
||||
Versão pequena: nome do estabelecimento padronizado para transferências entre contas ("Saída - Transf. entre contas" e "Entrada - Transf. entre contas") com anotação no formato "de {origem} -> {destino}", e correção de avisos `width(-1) and height(-1)` do `ChartContainer` no console.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}"
|
||||
@@ -167,6 +804,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.6.0] - 2026-02-18
|
||||
|
||||
Versão de personalização da tabela de lançamentos. Duas novas preferências em Ajustes > Extrato e lançamentos: "Anotações em coluna" (controla se a anotação aparece como coluna ou tooltip no ícone) e "Ordem das colunas" (lista ordenável por arrasto pra reordenar Estabelecimento, Transação, Valor etc.). Inclui ajustes mobile no header do dashboard (fixo só no mobile) e na rolagem horizontal de tabs e botões de ação.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone
|
||||
@@ -187,6 +826,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.5.3] - 2026-02-21
|
||||
|
||||
Versão focada no painel do pagador (novo card "Status de Pagamento" com totais pagos/pendentes e listagem individual de boletos com data de vencimento, data de pagamento e status), além de SEO completo na landing page (Open Graph, Twitter Card, JSON-LD Schema.org, sitemap.xml e robots.txt) e layout específico com metadados ricos. Imagens da landing convertidas de PNG para WebP para melhor performance.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Painel do pagador: card "Status de Pagamento" com totais pagos/pendentes e listagem individual de boletos com data de vencimento, data de pagamento e status
|
||||
@@ -208,6 +849,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.5.2] - 2026-02-16
|
||||
|
||||
Reforma visual da landing page: hero com gradient sutil e tipografia responsiva, dashboard preview sem bordas pra visual mais limpo, seção "Funcionalidades" reorganizada em 6 cards principais + 6 extras compactos, seção "Como usar" com tabs Docker (Recomendado) vs Manual e footer simplificado em 3 colunas. Inclui menu hamburger mobile com Sheet drawer, animações fade-in via Intersection Observer e seção dedicada ao OpenMonetis Companion com screenshots e fluxo de captura.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Landing page reformulada: visual modernizado, melhor experiência mobile e novas seções
|
||||
@@ -230,6 +873,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.5.1] - 2026-02-16
|
||||
|
||||
Esta versão renomeia o projeto de **OpenSheets** para **OpenMonetis** em todo o codebase (~40 arquivos: package.json, manifests, layouts, componentes, server actions, emails, Docker, docs e landing page). URLs do repositório atualizados de `opensheets-app` para `openmonetis`, image Docker renomeada para `felipegcoutinho/openmonetis` e logo textual atualizado. Inclui também suporte a multi-domínio via `PUBLIC_DOMAIN` (domínio público serve apenas a landing page, com middleware bloqueando rotas do app).
|
||||
|
||||
### Alterado
|
||||
|
||||
- Projeto renomeado de **OpenSheets** para **OpenMonetis** em todo o codebase (~40 arquivos): package.json, manifests, layouts, componentes, server actions, emails, Docker, docs e landing page
|
||||
@@ -244,6 +889,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.5.0] - 2026-02-15
|
||||
|
||||
Versão de personalização tipográfica: 13 fontes disponíveis (incluindo SF Pro Display, SF Pro Rounded, Inter, Geist Sans, Roboto, Reddit Sans, JetBrains Mono e outras) configuráveis por usuário tanto pra interface quanto pros valores monetários, com FontProvider que aplica a troca instantaneamente via CSS variables sem necessidade de reload. Fontes Apple SF Pro carregadas localmente com 4 pesos (Regular, Medium, Semibold, Bold) e novas colunas `system_font` e `money_font` na tabela `preferencias_usuario`.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Customização de fontes nas preferências — fonte da interface e fonte de valores monetários configuráveis por usuário
|
||||
@@ -263,6 +910,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.4.1] - 2026-02-15
|
||||
|
||||
Versão focada na inbox de pré-lançamentos: novas abas "Pendentes", "Processados" e "Descartados" (antes só pendentes), logo do cartão/conta exibido automaticamente nos cards via matching por nome do app, pre-fill automático do cartão de crédito ao processar e badges de status com data nos itens já processados/descartados em modo readonly. Cor `--warning` ajustada para melhor contraste (mais alaranjada).
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Abas "Pendentes", "Processados" e "Descartados" na página de pré-lançamentos (antes exibia apenas pendentes)
|
||||
@@ -284,6 +933,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.4.0] - 2026-02-07
|
||||
|
||||
Reforma do design system: ~60+ componentes migrados de cores hardcoded do Tailwind (`green-500`, `red-600`, `amber-500`, `blue-500` etc.) pra tokens semânticos (`success`, `destructive`, `warning`, `info`); adicionados novos tokens `--success`, `--warning`, `--info` (com foregrounds) tanto em light quanto dark mode, novas variantes `success` e `info` no Badge, e cores de chart estendidas de 6 para 10. Inclui também correção do bug de invalidação de cache do dashboard que impedia widgets de boleto/fatura de atualizar após pagamento, e fix de scroll em listas Popover+Command (estabelecimento, categorias, filtros) com a prop `modal`.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Widgets de boleto/fatura não atualizavam após pagamento: actions de fatura (`updateInvoicePaymentStatusAction`, `updatePaymentDateAction`) e antecipação de parcelas não invalidavam o cache do dashboard
|
||||
@@ -314,6 +965,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.3.1] - 2026-02-06
|
||||
|
||||
Versão pequena: calculadora arrastável via drag handle no header do dialog, callback `onSelectValue` pra inserir valor diretamente no campo de lançamento, e nova aba "Changelog" em Ajustes com histórico parseado do `CHANGELOG.md`. As páginas de itens ativos e arquivados em Cartões, Contas e Anotações foram unificadas com sistema de tabs (mesmo padrão de Categorias), eliminando rotas separadas e nomenclatura inconsistente.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Calculadora arrastável via drag handle no header do dialog
|
||||
@@ -329,6 +982,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.3.0] - 2026-02-06
|
||||
|
||||
Versão de performance no dashboard: indexes compostos em `lancamentos`, cache cross-request via `unstable_cache` com tag `"dashboard"` e TTL de 120s, e invalidação automática em mutations financeiras via `revalidateTag`. Eliminados ~20 JOINs com a tabela `pagadores` (substituídos por filtro direto via `pagadorId`) e queries consolidadas (income-expense-balance: 12→1 com GROUP BY; payment-status: 2→1; expenses/income por categoria: 4→2). Auth session deduplicada por request via `React.cache()` e scan de métricas limitado a 24 meses.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Indexes compostos em `lancamentos`: `(userId, period, transactionType)` e `(pagadorId, period)`
|
||||
@@ -349,6 +1004,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.2.6] - 2025-02-04
|
||||
|
||||
Versão de adaptação ao React 19 compiler: removidos ~60 `useCallback`/`useMemo` desnecessários, wrappers `React.memo` redundantes e simplificação de padrões de hidratação com `useSyncExternalStore`. Sem mudanças visíveis ao usuário — só faxina interna alinhada às novas otimizações automáticas do compilador.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Refatoração para otimização do React 19 compiler
|
||||
@@ -377,6 +1034,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.2.5] - 2025-02-01
|
||||
|
||||
Versão pequena: novo widget de pagadores no dashboard com avatares atualizados.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Widget de pagadores no dashboard
|
||||
@@ -384,6 +1043,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.2.4] - 2025-01-22
|
||||
|
||||
Correção pontual: preservação de formatação nas anotações e ajuste no layout do card de anotações.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Preservar formatação nas anotações
|
||||
@@ -391,6 +1052,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.2.3] - 2025-01-22
|
||||
|
||||
Versão pequena: versão do app passa a aparecer na sidebar e atualização da documentação.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Versão exibida na sidebar
|
||||
@@ -398,6 +1061,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [1.2.2] - 2025-01-22
|
||||
|
||||
Versão de manutenção: atualização de dependências e formatação aplicada em todo o código.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Atualização de dependências
|
||||
|
||||
366
CLAUDE.md
Normal file
366
CLAUDE.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 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, também altere o `package.json` e `readme.md` (Badges do README.md). Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna...").
|
||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
|
||||
|
||||
---
|
||||
|
||||
## 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/<feature>/`
|
||||
- Nao: vai para `src/shared/`
|
||||
|
||||
### Features nao importam outras features
|
||||
|
||||
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
|
||||
|
||||
**Excecao intencional: `attachments` depende de `transactions`**
|
||||
|
||||
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
|
||||
|
||||
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/
|
||||
│ │ ├── attachments/
|
||||
│ │ ├── changelog/
|
||||
│ │ ├── reports/
|
||||
│ │ │ ├── category-trends/
|
||||
│ │ │ ├── card-usage/
|
||||
│ │ │ ├── installment-analysis/
|
||||
│ │ │ └── establishments/
|
||||
│ │ └── settings/
|
||||
│ ├── (landing-page)/
|
||||
│ ├── api/
|
||||
│ ├── globals.css
|
||||
│ └── layout.tsx
|
||||
├── features/ # cada feature segue: actions.ts, queries.ts, actions/, components/, hooks/, lib/
|
||||
│ ├── auth/
|
||||
│ ├── landing/
|
||||
│ ├── dashboard/
|
||||
│ ├── transactions/
|
||||
│ ├── cards/
|
||||
│ ├── invoices/
|
||||
│ ├── accounts/
|
||||
│ ├── categories/
|
||||
│ ├── budgets/
|
||||
│ ├── payers/
|
||||
│ ├── notes/
|
||||
│ ├── insights/
|
||||
│ ├── calendar/
|
||||
│ ├── inbox/
|
||||
│ ├── attachments/
|
||||
│ ├── reports/
|
||||
│ └── settings/
|
||||
├── shared/
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui primitives
|
||||
│ │ ├── navigation/ # navbar, sidebar, breadcrumbs
|
||||
│ │ ├── providers/ # React context providers
|
||||
│ │ ├── brand/ # logos do app (logo, logo-icon, logo-text)
|
||||
│ │ ├── widgets/ # widget-card, widget-empty-state, expandable-widget-card
|
||||
│ │ ├── feedback/ # empty-state, status-dot, payment-success
|
||||
│ │ ├── month-picker/
|
||||
│ │ ├── logo-picker/
|
||||
│ │ ├── calculator/
|
||||
│ │ ├── entity-avatar/
|
||||
│ │ └── skeletons/
|
||||
│ ├── hooks/
|
||||
│ ├── lib/
|
||||
│ │ ├── actions/
|
||||
│ │ ├── auth/
|
||||
│ │ ├── accounts/
|
||||
│ │ ├── cards/
|
||||
│ │ ├── calculator/
|
||||
│ │ ├── categories/
|
||||
│ │ ├── email/
|
||||
│ │ ├── import/
|
||||
│ │ ├── installments/
|
||||
│ │ ├── invoices/
|
||||
│ │ ├── logo/
|
||||
│ │ ├── notifications/
|
||||
│ │ ├── payers/
|
||||
│ │ ├── schemas/
|
||||
│ │ ├── storage/
|
||||
│ │ ├── transfers/
|
||||
│ │ ├── types/
|
||||
│ │ ├── version/
|
||||
│ │ └── db.ts
|
||||
│ └── utils/
|
||||
│ ├── period/
|
||||
│ ├── calculator.ts
|
||||
│ ├── calendar.ts
|
||||
│ ├── category-colors.ts
|
||||
│ ├── currency.ts
|
||||
│ ├── date.ts
|
||||
│ ├── export-branding.ts
|
||||
│ ├── fetch-json.ts
|
||||
│ ├── financial-dates.ts
|
||||
│ ├── icons.tsx
|
||||
│ ├── id.ts
|
||||
│ ├── initials.ts
|
||||
│ ├── math.ts
|
||||
│ ├── number.ts
|
||||
│ ├── percentage.ts
|
||||
│ ├── string.ts
|
||||
│ └── ui.ts
|
||||
└── db/
|
||||
└── schema.ts
|
||||
```
|
||||
|
||||
### Estrutura interna padrão de uma feature
|
||||
|
||||
Toda feature em `src/features/<nome>/` segue:
|
||||
|
||||
```text
|
||||
<feature>/
|
||||
├── actions.ts # entry point de Server Actions (barrel quando há actions/)
|
||||
├── queries.ts # entry point de leitura do banco
|
||||
├── actions/ # (opcional) Server Actions divididas por domínio quando o volume cresce
|
||||
├── components/ # componentes de UI da feature
|
||||
├── hooks/ # React hooks específicos da feature
|
||||
└── lib/ # helpers, types, sub-queries e constantes internas
|
||||
```
|
||||
|
||||
`actions.ts` e `queries.ts` são as portas de entrada da feature. Tudo que é helper interno fica em `lib/`. Componentes e hooks ficam nas pastas com nome óbvio.
|
||||
|
||||
---
|
||||
|
||||
## 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 <TransactionsPage {...data} />;
|
||||
}
|
||||
```
|
||||
|
||||
Layouts, `loading.tsx` e metadata continuam em `src/app/`.
|
||||
|
||||
---
|
||||
|
||||
## Naming
|
||||
|
||||
### Routes / folders
|
||||
|
||||
| Portugues | English |
|
||||
|---|---|
|
||||
| `lancamentos` | `transactions` |
|
||||
| `cartoes` | `cards` |
|
||||
| `contas` | `accounts` |
|
||||
| `categorias` | `categories` |
|
||||
| `orcamentos` | `budgets` |
|
||||
| `pessoas` | `payers` |
|
||||
|
||||
> **Nota:** o conceito de "pagador" foi renomeado para **"pessoa"** na UI (labels, toasts, textos visíveis ao usuário). O código, rotas e schema continuam usando o termo original em inglês (`payer`, `payerId`, `adminPayerId`) e em português interno (`pagador` como variável). Não renomear esses identificadores — a divergência entre UI e código é intencional e documentada.
|
||||
| `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)/<feature>/page.tsx`
|
||||
2. Criar a feature em `src/features/<feature>/`
|
||||
3. Separar:
|
||||
- `components/`
|
||||
- `queries.ts` (entry point de leitura)
|
||||
- `actions.ts` (entry point de Server Actions; vira barrel quando crescer e migrar para `actions/`)
|
||||
- `lib/` para helpers internos, sub-queries por tópico, types e constantes da feature
|
||||
- `types.ts` ou `schemas.ts` quando fizer sentido (alternativa a `lib/`)
|
||||
- `hooks/` quando houver hooks específicos da feature
|
||||
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`
|
||||
|
||||
---
|
||||
|
||||
## Security Rules
|
||||
|
||||
Regras aplicadas automaticamente ao gerar codigo.
|
||||
|
||||
### Secrets
|
||||
Nunca colocar API keys, credenciais de banco ou tokens em codigo frontend. Evitar variaveis prefixadas com `NEXT_PUBLIC_` para dados sensiveis — estas sao bundladas no cliente. Usar variaveis server-side apenas. `.env` deve estar no `.gitignore` antes do primeiro commit. `.env.example` deve ter apenas placeholders.
|
||||
|
||||
### Autenticacao & Autorizacao
|
||||
Toda rota protegida em `src/app/api/` requer `getUser()` ou `getOptionalUserSession()` antes de qualquer logica, retornando 401 para nao autenticados. Rotas com IDs de recursos devem verificar ownership: `eq(table.userId, userId)`. Rotas admin devem checar role e retornar 403 para nao-admins. Session cookies em Better Auth ja tem `httpOnly`, `secure` e `sameSite` configurados — nao alterar.
|
||||
|
||||
### Input & Output
|
||||
Usar Drizzle ORM (parametrizado por padrao) — nunca concatenar input de usuario em SQL. Validar todo input com Zod antes de usar. Upload de arquivos: usar whitelist de MIME types (`ALLOWED_MIME_TYPES`), presigned URLs para S3, token de upload assinado com verificacao pos-upload. Nunca usar `dangerouslySetInnerHTML` com conteudo de usuario.
|
||||
|
||||
### Headers & CSP
|
||||
CSP definida em `src/proxy.ts` via middleware — alterar la, nao em `next.config.ts`. Headers de seguranca (HSTS, X-Frame-Options, etc.) definidos em `next.config.ts`. Nao remover nem enfraquecer essas configuracoes.
|
||||
|
||||
### Rate Limiting
|
||||
Login: 5 tentativas/min. Signup: 3 tentativas/min. API tokens: 100 req/min (inbox), 20 req/min (batch). Configurado em `src/shared/lib/auth/config.ts` e nas rotas de inbox. Nao remover.
|
||||
|
||||
### Tratamento de Erros
|
||||
Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Usar mensagens genericas: `"Algo deu errado"`. Logar detalhes apenas no servidor com `console.error()`.
|
||||
|
||||
### Dependencias
|
||||
Verificar pacotes novos sugeridos pela IA em npmjs.com antes de instalar. Red flags: menos de 1.000 downloads/semana, publicado nos ultimos 30 dias, nome muito parecido com pacote popular. Rodar `pnpm audit` periodicamente.
|
||||
|
||||
---
|
||||
389
DESIGN.md
Normal file
389
DESIGN.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Design System Inspired by OpenMonetis
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
|
||||
OpenMonetis embodies a warm, approachable financial management aesthetic grounded in trust and transparency. The design system combines a rich warm-orange accent palette with a sophisticated warm-neutral foundation, creating an interface that feels both professional and inviting. The typography and spacing work together to emphasize clarity and hierarchy, supporting the open-source ethos of personal financial control. The visual atmosphere prioritizes legibility and calm navigation, with generous whitespace and deliberate color restraint—the bold orange is reserved for critical calls-to-action and highlights, while the warm grays and blacks anchor the interface with stability and focus.
|
||||
|
||||
**Key Characteristics**
|
||||
- Warm, approachable color story with a dominant orange accent (`#FF7733`)
|
||||
- Generous whitespace and breathing room between sections
|
||||
- High contrast between backgrounds and text for accessibility
|
||||
- Clear typographic hierarchy using Inter for all text and UI
|
||||
- Minimal elevation and shadow treatment—mostly flat design
|
||||
- Subtle border accents in warm grays to define surfaces
|
||||
- Open-source transparency reflected in straightforward, honest design language
|
||||
|
||||
## 2. Color Palette & Roles
|
||||
|
||||
### Primary
|
||||
- **Primary Accent** (`#FF7733`): Used for primary call-to-action buttons, highlights, and key interactive elements throughout the interface; draws user attention to the most important actions
|
||||
- **Primary Dark** (`#443732`): Warm-brown anchor color used extensively for text, headings, and interactive elements; provides the primary text color across the system
|
||||
|
||||
### Interactive
|
||||
- **Interactive Neutral** (`#0F0D0C`): Near-black used for primary text and strong emphasis elements; highest contrast state
|
||||
- **Interactive Overlay** (`#0006`): Transparent black overlay at 24% opacity; used for hover states, modals, and depth layering
|
||||
|
||||
### Neutral Scale
|
||||
- **Neutral 900** (`#2A2827`): Very dark warm gray; used for secondary text and disabled states
|
||||
- **Neutral 800** (`#322C2A`): Dark warm gray; alternative text color for lower-emphasis content
|
||||
- **Neutral 700** (`#676260`): Medium warm gray; used for tertiary text, captions, and metadata
|
||||
- **Neutral 50** (`#FCF7F6`): Almost-white warm cream; primary background color for light surfaces
|
||||
- **Neutral 100** (`#F8F6F4`): Very light warm gray; secondary background and subtle surface distinction
|
||||
- **Neutral 200** (`#F5F2EF`): Light warm gray; tertiary background and card interiors
|
||||
- **Neutral 300** (`#F0EEEC`): Pale warm gray; border and divider lines
|
||||
|
||||
### Surface & Borders
|
||||
- **Surface** (`#FFFFFF`): Pure white; primary card and container background; high-contrast surface
|
||||
- **Border Light** (`#F0EEEC`): Pale warm gray used for subtle borders between elements
|
||||
- **Border Dark** (`#2A2827`): Dark warm gray; stronger borders for defined card boundaries
|
||||
|
||||
### Semantic / Status
|
||||
- **Success** (`#0E9D6E`): Green used for positive status indicators, confirmation states, and success messages
|
||||
- **Warning** (`#F7A439`): Amber used for cautionary states, warnings, and attention-drawing alerts
|
||||
- **Error** (`#F53F2D`): Red-orange used for error states, destructive actions, and validation failures
|
||||
- **Error Alt** (`#D40C1A`): Deep red alternative for critical errors and danger states
|
||||
|
||||
## 3. Typography Rules
|
||||
|
||||
### Font Family
|
||||
**Primary:** Inter (sans-serif)
|
||||
Fallback: `Inter, system-ui, -apple-system, sans-serif`
|
||||
|
||||
**Monospace:** ui-monospace
|
||||
Fallback: `ui-monospace, 'Courier New', monospace`
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|
||||
|------|------|------|--------|-------------|----------------|-------|
|
||||
| Display / H1 | Inter | 60px | 600 | 60px | 0px | Page titles and hero headlines; maximum visual impact |
|
||||
| Heading H2 | Inter | 36px | 600 | 40px | 0px | Section headers and major subsections |
|
||||
| Heading H3 | Inter | 16px | 600 | 20px | 0px | Card titles and smaller section headers |
|
||||
| Body | Inter | 20px | 400 | 28px | 0px | Descriptive text, paragraphs, and long-form content |
|
||||
| Body Secondary | Inter | 16px | 400 | 24px | 0px | Standard body text, list items, and descriptions |
|
||||
| Emphasis / Span | Inter | 14px | 500 | 20px | 0px | Emphasized text, labels, and badge content |
|
||||
| Button | Inter | 14px | 500 | 20px | 0px | All button text; medium weight for clarity |
|
||||
| Caption | Inter | 14px | 400 | 20px | 0px | Metadata, timestamps, and small supporting text |
|
||||
| Code | ui-monospace | 14px | 400 | 20px | 0px | Code blocks and technical content |
|
||||
|
||||
### Principles
|
||||
- **Contrast & Clarity:** Text color `#0F0D0C` on light backgrounds; `#FFFFFF` on dark backgrounds
|
||||
- **Weight Hierarchy:** Use 600 weight for all headings; 500 for interactive/emphasized text; 400 for body
|
||||
- **Scale Progression:** Sizes increase in meaningful increments (14 → 16 → 20 → 36 → 60); maintain consistent rhythm
|
||||
- **Line Height:** Body text uses 1.4× line height multiplier for comfortable reading; headings use tighter ratio (1:1) for impact
|
||||
- **Readability First:** Avoid line lengths over 80 characters for long-form content; increase line height on smaller screens
|
||||
|
||||
## 4. Component Stylings
|
||||
|
||||
### Buttons
|
||||
|
||||
#### Primary Button
|
||||
- **Background:** `#FF7733`
|
||||
- **Text Color:** `#FFFFFF`
|
||||
- **Font Size:** `14px`
|
||||
- **Font Weight:** `500`
|
||||
- **Font Family:** `Inter`
|
||||
- **Padding:** `8px 16px`
|
||||
- **Border Radius:** `9.2px`
|
||||
- **Border:** `0px solid transparent`
|
||||
- **Height:** `40px`
|
||||
- **Box Shadow:** `none`
|
||||
- **Hover State:** Darken background to `#E55F1F`; add subtle shadow `0px 2px 8px rgba(0, 0, 0, 0.12)`
|
||||
- **Active State:** Darken further to `#CC5118`; increase shadow
|
||||
- **Disabled State:** Background `#E8E3E0`; text color `#999890`; cursor not-allowed
|
||||
|
||||
#### Secondary Button
|
||||
- **Background:** `#FFFFFF`
|
||||
- **Text Color:** `#2A2827`
|
||||
- **Font Size:** `14px`
|
||||
- **Font Weight:** `500`
|
||||
- **Font Family:** `Inter`
|
||||
- **Padding:** `8px 24px`
|
||||
- **Border Radius:** `9.2px`
|
||||
- **Border:** `1px solid #F0EEEC`
|
||||
- **Height:** `40px`
|
||||
- **Box Shadow:** `0px 1px 3px rgba(0, 0, 0, 0.06)`
|
||||
- **Hover State:** Background `#F8F6F4`; border color `#E8E3E0`
|
||||
- **Active State:** Background `#F0EEEC`; border color `#D5CCCA`
|
||||
- **Disabled State:** Background `#FAFAF8`; text color `#BFBAB7`; border color `#F0EEEC`
|
||||
|
||||
#### Ghost Button
|
||||
- **Background:** `transparent`
|
||||
- **Text Color:** `#443732`
|
||||
- **Font Size:** `14px`
|
||||
- **Font Weight:** `500`
|
||||
- **Font Family:** `Inter`
|
||||
- **Padding:** `6px 8px`
|
||||
- **Border Radius:** `9.2px`
|
||||
- **Border:** `0px solid transparent`
|
||||
- **Height:** `32px`
|
||||
- **Box Shadow:** `none`
|
||||
- **Hover State:** Background `rgba(68, 55, 50, 0.05)`
|
||||
- **Active State:** Background `rgba(68, 55, 50, 0.1)`
|
||||
- **Disabled State:** Text color `#BFBAB7`; cursor not-allowed
|
||||
|
||||
#### Icon Button
|
||||
- **Background:** `transparent`
|
||||
- **Icon Color:** `#443732`
|
||||
- **Size:** `32px` × `32px`
|
||||
- **Border Radius:** `9.2px`
|
||||
- **Border:** `0px solid transparent`
|
||||
- **Padding:** `0px`
|
||||
- **Box Shadow:** `none`
|
||||
- **Hover State:** Background `rgba(68, 55, 50, 0.08)`
|
||||
- **Active State:** Background `rgba(68, 55, 50, 0.12)`
|
||||
|
||||
### Cards & Containers
|
||||
|
||||
#### Standard Card
|
||||
- **Background:** `#FFFFFF`
|
||||
- **Border:** `1px solid #F0EEEC`
|
||||
- **Border Radius:** `11.2px`
|
||||
- **Padding:** `24px`
|
||||
- **Box Shadow:** `none`
|
||||
- **Text Color:** `#2A2827`
|
||||
- **Hover State:** Border color `#E8E3E0`; box-shadow `0px 4px 12px rgba(0, 0, 0, 0.08)`
|
||||
|
||||
#### Card with Top Border
|
||||
- **Background:** `#FFFFFF`
|
||||
- **Border:** `1px solid #F0EEEC`
|
||||
- **Border Radius:** `15.2px 15.2px 0px 0px` (top corners only)
|
||||
- **Padding:** `24px`
|
||||
- **Box Shadow:** `none`
|
||||
- **Top Border Color:** `#FF7733` (3px height implied)
|
||||
|
||||
#### Surface Container (Header/Nav)
|
||||
- **Background:** `#FF7733`
|
||||
- **Height:** `64px`
|
||||
- **Padding:** `0px 24px`
|
||||
- **Box Shadow:** `none`
|
||||
- **Text Color:** `#FFFFFF`
|
||||
- **Border:** `0px solid transparent`
|
||||
|
||||
#### Light Surface
|
||||
- **Background:** `#F8F6F4`
|
||||
- **Border:** `0px solid transparent`
|
||||
- **Border Radius:** `11.2px`
|
||||
- **Padding:** `16px`
|
||||
- **Box Shadow:** `none`
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
#### Text Input
|
||||
- **Background:** `#FFFFFF`
|
||||
- **Border:** `1px solid #F0EEEC`
|
||||
- **Border Radius:** `9.2px`
|
||||
- **Padding:** `12px 16px`
|
||||
- **Font Size:** `16px`
|
||||
- **Text Color:** `#2A2827`
|
||||
- **Line Height:** `24px`
|
||||
- **Placeholder Color:** `#999890`
|
||||
- **Focus State:** Border color `#FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
|
||||
- **Error State:** Border color `#F53F2D`; background `#FEF5F3`
|
||||
- **Disabled State:** Background `#F8F6F4`; text color `#BFBAB7`; border color `#F0EEEC`
|
||||
|
||||
#### Select / Dropdown
|
||||
- **Background:** `#FFFFFF`
|
||||
- **Border:** `1px solid #F0EEEC`
|
||||
- **Border Radius:** `9.2px`
|
||||
- **Padding:** `12px 16px`
|
||||
- **Font Size:** `16px`
|
||||
- **Text Color:** `#2A2827`
|
||||
- **Focus State:** Border color `#FF7733`; outline `0px`
|
||||
- **Hover State:** Background `#FAFAF8`
|
||||
|
||||
#### Checkbox & Radio
|
||||
- **Size:** `20px` × `20px`
|
||||
- **Border Radius:** `4px` (checkbox), `50%` (radio)
|
||||
- **Border:** `2px solid #F0EEEC`
|
||||
- **Background:** `#FFFFFF`
|
||||
- **Checked Background:** `#FF7733`
|
||||
- **Checked Border:** `2px solid #FF7733`
|
||||
- **Checked Icon Color:** `#FFFFFF`
|
||||
- **Focus:** Border `2px solid #FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
|
||||
|
||||
### Navigation
|
||||
|
||||
#### Primary Navigation
|
||||
- **Background:** `#FF7733`
|
||||
- **Height:** `64px`
|
||||
- **Padding:** `0px 48px`
|
||||
- **Display:** flex; align-items: center; gap `32px`
|
||||
- **Link Color:** `#FFFFFF`
|
||||
- **Link Font Size:** `16px`
|
||||
- **Link Font Weight:** `400`
|
||||
- **Link Hover:** Opacity `0.8`
|
||||
- **Link Active:** Text decoration underline; opacity `1.0`
|
||||
|
||||
#### Secondary Navigation / Tabs
|
||||
- **Background:** `transparent`
|
||||
- **Border Bottom:** `2px solid #F0EEEC`
|
||||
- **Tab Padding:** `16px 24px`
|
||||
- **Tab Color:** `#676260`
|
||||
- **Tab Font Size:** `16px`
|
||||
- **Tab Hover:** Color `#443732`
|
||||
- **Tab Active:** Color `#FF7733`; border-bottom color `#FF7733`
|
||||
|
||||
#### Breadcrumb Navigation
|
||||
- **Font Size:** `14px`
|
||||
- **Color:** `#676260`
|
||||
- **Separator:** `/` with `0px 8px` margin
|
||||
- **Link Color:** `#443732`
|
||||
- **Link Hover:** Color `#FF7733`
|
||||
- **Current (Active):** Color `#2A2827`; font-weight `500`
|
||||
|
||||
### Badges & Status Indicators
|
||||
|
||||
#### Badge – Default
|
||||
- **Background:** `#F8F6F4`
|
||||
- **Text Color:** `#443732`
|
||||
- **Padding:** `4px 12px`
|
||||
- **Border Radius:** `20px`
|
||||
- **Font Size:** `12px`
|
||||
- **Font Weight:** `500`
|
||||
- **Border:** `0px solid transparent`
|
||||
|
||||
#### Badge – Success
|
||||
- **Background:** `#E8F5F0`
|
||||
- **Text Color:** `#0E9D6E`
|
||||
- **Padding:** `4px 12px`
|
||||
- **Border Radius:** `20px`
|
||||
- **Font Size:** `12px`
|
||||
- **Font Weight:** `500`
|
||||
|
||||
#### Badge – Warning
|
||||
- **Background:** `#FEF5E8`
|
||||
- **Text Color:** `#F7A439`
|
||||
- **Padding:** `4px 12px`
|
||||
- **Border Radius:** `20px`
|
||||
- **Font Size:** `12px`
|
||||
- **Font Weight:** `500`
|
||||
|
||||
#### Badge – Error
|
||||
- **Background:** `#FEF5F3`
|
||||
- **Text Color:** `#F53F2D`
|
||||
- **Padding:** `4px 12px`
|
||||
- **Border Radius:** `20px`
|
||||
- **Font Size:** `12px`
|
||||
- **Font Weight:** `500`
|
||||
|
||||
## 5. Layout Principles
|
||||
|
||||
### Spacing System
|
||||
- **Base Unit:** `4px`
|
||||
- **Scale:** `4px`, `8px`, `12px`, `16px`, `24px`, `32px`, `48px`, `56px`, `64px`, `80px`, `96px`, `128px`
|
||||
|
||||
**Usage Contexts:**
|
||||
- **4–8px:** Tight spacing within compact components (icon-text pairs, inline elements)
|
||||
- **12–16px:** Standard padding inside cards, inputs, and buttons
|
||||
- **24–32px:** Section gaps, spacing between components on a page
|
||||
- **48–64px:** Large section separations, hero spacing
|
||||
- **80–128px:** Hero margins, page-level vertical rhythm
|
||||
|
||||
### Grid & Container
|
||||
- **Max Width:** `1440px` for full-width containers
|
||||
- **Content Width:** `1152px` for typical page layouts
|
||||
- **Column Strategy:** 12-column grid system; gutter `24px`
|
||||
- **Container Padding:** `48px` on desktop (left + right)
|
||||
- **Section Pattern:** Full-width containers with internal max-width constraint
|
||||
|
||||
### Whitespace Philosophy
|
||||
OpenMonetis prioritizes breathing room and visual clarity. Whitespace is intentional and strategic—surrounding headings, separating card groups, and framing key messages. The warm neutral backgrounds (`#F8F6F4`, `#F5F2EF`) create natural visual separation without hard borders. Minimum margin between major sections is `64px` vertically; minimum padding inside containers is `16px`.
|
||||
|
||||
### Border Radius Scale
|
||||
- **Sharp Corners:** `0px` (utility container tops, category selectors)
|
||||
- **Subtle Radius:** `9.2px` (buttons, small inputs, icon buttons)
|
||||
- **Standard Radius:** `11.2px` (cards, standard containers, modals)
|
||||
- **Rounded Top:** `15.2px 15.2px 0px 0px` (card headers, sheet-style containers)
|
||||
- **Pill Shape:** `24px` (badges, full-rounded tags, avatar images)
|
||||
- **Circle:** `50%` (avatar images, radial elements)
|
||||
|
||||
## 6. Depth & Elevation
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|-------|-----------|-----|
|
||||
| Flat (None) | No shadow, `border: 1px solid #F0EEEC` | Default cards, inputs, containers; baseline surfaces |
|
||||
| Subtle (sm) | `0px 1px 3px rgba(0, 0, 0, 0.06)` | Secondary buttons, hover states on light surfaces |
|
||||
| Medium (md) | `0px 4px 12px rgba(0, 0, 0, 0.08)` | Elevated cards on hover, floating actions |
|
||||
| Deep (lg) | `0px 10px 24px rgba(0, 0, 0, 0.12)` | Modals, dropdowns, popover menus |
|
||||
|
||||
**Shadow Philosophy:**
|
||||
The design system uses restrained shadow treatment aligned with a flat-modern aesthetic. Shadows emerge subtly on interaction (hover, focus) rather than as default styling. The primary depth cue is border color (`#F0EEEC`), which maintains visual hierarchy without excessive z-depth. When shadows are used, they employ warm-tinted blacks (`rgba(0, 0, 0, 0.06–0.12)`) to harmonize with the warm neutral palette.
|
||||
|
||||
## 7. Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use the primary orange (`#FF7733`) exclusively for the most important call-to-action buttons
|
||||
- Apply generous padding (`24px–48px`) around sections and inside cards for breathing room
|
||||
- Stack elements vertically with `24–32px` gaps for clear visual rhythm
|
||||
- Use warm grays (`#443732`, `#2A2827`) for all body text to maintain the warm aesthetic
|
||||
- Apply `9.2px` border radius consistently to all interactive elements (buttons, inputs)
|
||||
- Keep line heights at `1.4×` or greater for comfortable reading on body text
|
||||
- Use semantic colors (`#0E9D6E` success, `#F7A439` warning, `#F53F2D` error) intentionally
|
||||
- Test contrast ratios; maintain WCAG AA minimum (4.5:1 for body text)
|
||||
- Use the `Inter` typeface exclusively for consistency
|
||||
- Implement focus states with a `3px` colored outline or border
|
||||
|
||||
### Don't
|
||||
- Don't use orange anywhere except primary CTAs and critical highlights
|
||||
- Don't reduce padding below `12px` inside cards or `8px` inside compact buttons
|
||||
- Don't use dark backgrounds (`#0F0D0C`) for body text on light surfaces; stick to `#2A2827` or `#443732`
|
||||
- Don't apply shadows as default styling; reserve them for elevated states (hover, focus, modal)
|
||||
- Don't mix border radius values on the same component type; stick to defined scale
|
||||
- Don't increase line height above `1.6×` for headings; tighten for impact
|
||||
- Don't use the error color (`#F53F2D`) for general emphasis; reserve for genuine errors
|
||||
- Don't create new colors outside the palette; use opacity if gradation is needed
|
||||
- Don't apply multiple shadows to a single element; layer a maximum of two shadow values
|
||||
- Don't forget to include focus/keyboard navigation states on all interactive elements
|
||||
|
||||
## 8. Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Breakpoint | Width | Key Changes |
|
||||
|-----------|-------|-------------|
|
||||
| Mobile | `375px–599px` | Single column; container padding `16px`; font sizes reduce 1–2 sizes; gap scale halved |
|
||||
| Tablet | `600px–1023px` | Two-column grid; container padding `32px`; button height `36px`; heading sizes reduce slightly |
|
||||
| Desktop | `1024px+` | Full 12-column grid; container padding `48px`; full-scale typography; max-width `1440px` |
|
||||
|
||||
### Touch Targets
|
||||
- **Minimum Interactive Size:** `44px` × `44px` for mobile; `40px` × `40px` for desktop
|
||||
- **Button Padding:** `12px` vertical, `16px` horizontal (minimum)
|
||||
- **Link Padding:** `6px` vertical, `8px` horizontal minimum
|
||||
- **Icon Button:** `32px` × `32px` minimum (32px on mobile preferred)
|
||||
- **Spacing Between Targets:** `8px` minimum to avoid accidental activation
|
||||
|
||||
### Collapsing Strategy
|
||||
- **Navigation:** Horizontal nav on desktop collapses to hamburger menu on tablet; menu items stack vertically with `12px` gap
|
||||
- **Grid:** 12-column layout on desktop → 6-column on tablet → 2-column (stacked) on mobile
|
||||
- **Cards:** Three-column card layouts collapse to single column on mobile; padding reduces from `24px` to `16px`
|
||||
- **Typography:** Display (60px) → 36px on tablet → 28px on mobile; body (20px) → 18px on tablet → 16px on mobile
|
||||
- **Spacing:** All spacing scale values reduce by 25–33% on mobile (e.g., `24px` gap → `16px` on tablet, `12px` on mobile)
|
||||
- **Buttons:** Full-width on mobile (padding `0px`); inline (auto-width) on desktop
|
||||
- **Inputs:** Full-width on mobile; constrained width on desktop
|
||||
|
||||
## 9. Agent Prompt Guide
|
||||
|
||||
### Quick Color Reference
|
||||
- **Primary CTA:** Warm Orange (`#FF7733`) — Buttons, highlights, key interactions
|
||||
- **Primary Text:** Warm Brown (`#443732`) — Headings, strong emphasis
|
||||
- **Secondary Text:** Dark Neutral (`#2A2827`) — Body text, card content
|
||||
- **Background:** White (`#FFFFFF`) — Cards, primary surfaces
|
||||
- **Background Alt:** Cream (`#FCF7F6`) — Alternative surfaces, light containers
|
||||
- **Border:** Pale Gray (`#F0EEEC`) — Card borders, divider lines
|
||||
- **Success:** Green (`#0E9D6E`) — Confirmation, positive states
|
||||
- **Warning:** Amber (`#F7A439`) — Cautions, attention states
|
||||
- **Error:** Red-Orange (`#F53F2D`) — Errors, destructive actions
|
||||
- **Disabled:** Light Gray (`#E8E3E0`) — Inactive elements, inaccessible states
|
||||
|
||||
### Iteration Guide
|
||||
1. **Always use `#FF7733` for primary buttons** and all main call-to-action elements; secondary buttons use `#FFFFFF` with `#F0EEEC` border
|
||||
2. **Typography is always `Inter`** with weights 400 (body), 500 (emphasis), and 600 (headings); size hierarchy: 14 → 16 → 20 → 36 → 60px
|
||||
3. **Spacing base is `4px`**; use multiples from the scale (8, 12, 16, 24, 32, 48, 64, 80, 96, 128px); never arbitrary values
|
||||
4. **Border radius:** Apply `9.2px` to all buttons and inputs, `11.2px` to cards, `15.2px 15.2px 0px 0px` to top-bordered containers
|
||||
5. **Cards default to `#FFFFFF` background with `1px solid #F0EEEC` border**; add shadow only on hover (0px 4px 12px rgba(0, 0, 0, 0.08))
|
||||
6. **Form inputs:** Padding `12px 16px`, border `1px solid #F0EEEC`, focus state `border: 1px solid #FF7733` + `box-shadow: 0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
|
||||
7. **Navigation background is always `#FF7733`** with white text; links in content use `#443732` with underline on active
|
||||
8. **Maintain 1.4× line height minimum for body text**; tighten headings to 1:1 or 1.2:1 ratio
|
||||
9. **Contrast validation:** Text `#0F0D0C` or `#2A2827` on light backgrounds (WCAG AA); text `#FFFFFF` on `#FF7733` (WCAG AAA)
|
||||
10. **Responsive collapse:** Reduce padding and font by 25% on mobile; stack multi-column layouts to single column; full-width buttons on mobile only
|
||||
64
Dockerfile
64
Dockerfile
@@ -5,14 +5,16 @@
|
||||
# ============================================
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Criar pasta public para o postinstall do pdfjs-dist
|
||||
RUN mkdir -p public
|
||||
|
||||
# Instalar dependências (production + dev para o build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -21,8 +23,7 @@ RUN pnpm install --frozen-lockfile
|
||||
# ============================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -32,13 +33,18 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copiar todo o código fonte
|
||||
COPY . .
|
||||
|
||||
# Garantir que o pdf.worker vem da versão instalada no stage 1, não do host
|
||||
COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
|
||||
|
||||
# Variáveis de ambiente necessárias para o build
|
||||
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
# Nota: a integração Logo.dev não precisa mais de build args. O token
|
||||
# `LOGO_DEV_TOKEN` é lido em runtime no servidor — basta configurá-lo no
|
||||
# host (Coolify, Railway, etc.) junto com `LOGO_DEV_SECRET_KEY`.
|
||||
|
||||
# Build da aplicação Next.js
|
||||
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
|
||||
RUN pnpm build
|
||||
|
||||
# ============================================
|
||||
@@ -46,8 +52,7 @@ RUN pnpm build
|
||||
# ============================================
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -55,22 +60,40 @@ WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
|
||||
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
|
||||
COPY --from=builder /app/package.json /tmp/pkg.json
|
||||
RUN mkdir -p /app/migrate && \
|
||||
node -e "\
|
||||
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
|
||||
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
|
||||
name:'openmonetis-migrate',version:p.version,\
|
||||
dependencies:{\
|
||||
'drizzle-orm':p.dependencies['drizzle-orm'],\
|
||||
'pg':p.dependencies['pg']\
|
||||
},\
|
||||
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
|
||||
}));" && \
|
||||
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
|
||||
chown -R nextjs:nodejs /app/migrate
|
||||
|
||||
# Copiar arquivos de build do Next.js
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copiar arquivos do Drizzle (migrations e schema)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/db ./db
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
||||
|
||||
# Copiar node_modules para ter drizzle-kit disponível para migrations
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
# Copiar entrypoint de migrations
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN sed -i 's/\r$//' /app/docker-entrypoint.sh && \
|
||||
chmod +x /app/docker-entrypoint.sh && \
|
||||
chown nextjs:nodejs /app/docker-entrypoint.sh
|
||||
|
||||
# Definir variáveis de ambiente de produção
|
||||
ENV NODE_ENV=production \
|
||||
@@ -81,16 +104,13 @@ ENV NODE_ENV=production \
|
||||
# Expor porta
|
||||
EXPOSE 3000
|
||||
|
||||
# Ajustar permissões para o usuário nextjs
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
# Mudar para usuário não-root
|
||||
USER nextjs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
|
||||
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
# Comando de inicialização
|
||||
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
|
||||
# Entrypoint: roda migrations e depois executa o CMD
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
448
README.md
448
README.md
@@ -1,15 +1,14 @@
|
||||
<p align="center">
|
||||
<img src="./public/logo_small.png" alt="OpenMonetis Logo" height="80" />
|
||||
<img src="./public/images/logo_small.svg" alt="OpenMonetis Logo" height="80" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Projeto pessoal de gestão financeira. Self-hosted, manual e open source.
|
||||
</p>
|
||||
|
||||
> **📢 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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -21,7 +20,7 @@
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
|
||||
<img src="./public/images/dashboard-preview-light.png" alt="Dashboard Preview" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -29,9 +28,13 @@
|
||||
## 📖 Índice
|
||||
|
||||
- [Sobre o Projeto](#-sobre-o-projeto)
|
||||
- [Início Rápido](#-início-rápido)
|
||||
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
|
||||
- [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
|
||||
- [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
|
||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||
- [Docker](#-docker)
|
||||
- [Backup](#-backup)
|
||||
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||
- [Arquitetura](#-arquitetura)
|
||||
- [Contribuindo](#-contribuindo)
|
||||
@@ -52,15 +55,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, usar o app companion para capturar notificações bancárias 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, filtros combináveis, 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.
|
||||
|
||||
@@ -76,15 +79,19 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
|
||||
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
|
||||
|
||||
📲 **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).
|
||||
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia automaticamente como pré-lançamentos para revisão — sem digitar nada. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
|
||||
|
||||
⚙️ **Personalização** — Tema dark/light, modo privacidade, fontes customizáveis, preferências por usuário.
|
||||
<p align="center">
|
||||
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
|
||||
</p>
|
||||
|
||||
⚙️ **Personalização** — Tema dark/light, modo privacidade e changelog visual para acompanhar as novidades do app.
|
||||
|
||||
### 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,59 +99,118 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Início Rápido
|
||||
## 🚀 Como rodar o OpenMonetis
|
||||
|
||||
### Pré-requisitos
|
||||
Escolha o perfil que corresponde ao seu objetivo:
|
||||
|
||||
- Node.js 22+ e pnpm
|
||||
- Docker e Docker Compose
|
||||
| | Perfil 1 — Usar | Perfil 2 — Desenvolver |
|
||||
|---|---|---|
|
||||
| **Objetivo** | Rodar o app pronto | Modificar o código |
|
||||
| **Clonar repositório** | Não | Sim |
|
||||
| **Node.js / pnpm** | Não | Sim (Node 22+) |
|
||||
| **Docker** | Sim | Sim |
|
||||
| **Como iniciar** | `docker compose up -d` | `pnpm docker:db` + `pnpm dev` |
|
||||
| **App roda em** | Container Docker | Host local (hot-reload) |
|
||||
| **Banco roda em** | Container Docker | Container Docker |
|
||||
| **`DATABASE_URL` (host)** | `db` (automático pelo compose) | `localhost` |
|
||||
| **Banco remoto (Supabase, Neon...)** | Sim (`docker compose up -d app`) | Sim (ajustar `DATABASE_URL`) |
|
||||
| **Como atualizar** | `pnpm docker:update` | `git pull` + `pnpm install` + `pnpm db:push` |
|
||||
| **Indicado para** | Self-hosting, VPS, servidor | Contribuidores, customizações |
|
||||
|
||||
### Passo a Passo
|
||||
---
|
||||
|
||||
1. **Clone e instale**
|
||||
### Perfil 1 — Usar (self-hosting)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||
cd openmonetis
|
||||
pnpm install
|
||||
```
|
||||
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
|
||||
|
||||
2. **Configure o `.env`**
|
||||
```bash
|
||||
# 1. Baixe o compose
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
# 2. Crie um .en na mesma pasta.
|
||||
# .env mínimo recomendado para produção
|
||||
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
||||
BETTER_AUTH_URL=http://seu-dominio.com
|
||||
|
||||
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`:
|
||||
# 3. Suba tudo
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
```env
|
||||
# Banco local (Docker): use host "localhost"
|
||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||
Acesse em: `http://localhost:3000`
|
||||
|
||||
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider
|
||||
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
|
||||
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir.
|
||||
|
||||
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
```
|
||||
Mais sobre .env em [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||
|
||||
3. **Suba o banco de dados** (pule se estiver usando banco remoto)
|
||||
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
|
||||
|
||||
```bash
|
||||
docker compose up db -d
|
||||
pnpm db:enableExtensions
|
||||
```
|
||||
```bash
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
4. **Execute as migrations e inicie**
|
||||
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
|
||||
|
||||
```bash
|
||||
pnpm db:push
|
||||
pnpm dev
|
||||
```
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||
sudo sh install-deps.sh
|
||||
```
|
||||
|
||||
5. Acesse `http://localhost:3000`
|
||||
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
|
||||
|
||||
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4.
|
||||
#### Atualizando (Perfil 1)
|
||||
|
||||
```bash
|
||||
pnpm docker:update
|
||||
# ou equivalente:
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
|
||||
|
||||
---
|
||||
|
||||
### Perfil 2 — Desenvolver
|
||||
|
||||
Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
|
||||
|
||||
**Requisitos:** Docker + Node.js 22+ + pnpm
|
||||
|
||||
```bash
|
||||
# 1. Clone o repositório
|
||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||
cd openmonetis
|
||||
|
||||
# 2. Instale as dependências
|
||||
pnpm install
|
||||
|
||||
# 3. Configure o ambiente
|
||||
cp .env.example .env
|
||||
# O DATABASE_URL já vem com host "localhost" (correto para dev local).
|
||||
# Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
|
||||
|
||||
# 4. Suba o banco
|
||||
pnpm docker:db
|
||||
|
||||
# 5. Aplique o schema no banco (apenas no primeiro setup)
|
||||
pnpm db:push
|
||||
|
||||
# 6. Inicie o app com hot-reload
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Acesse em: `http://localhost:3000`
|
||||
|
||||
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
|
||||
|
||||
#### Atualizando (Perfil 2)
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install # instala dependências novas, se houver
|
||||
pnpm db:push # aplica mudanças de schema, se houver
|
||||
```
|
||||
|
||||
O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,38 +235,57 @@ pnpm db:push # Push schema direto (dev)
|
||||
pnpm db:studio # Drizzle Studio (UI visual)
|
||||
```
|
||||
|
||||
### Utilitários
|
||||
|
||||
```bash
|
||||
pnpm backup # Backup completo do banco (ver seção Backup)
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
pnpm docker:up # Subir app + banco
|
||||
pnpm docker:up:d # Subir em background
|
||||
pnpm docker:up:db # Subir apenas o banco
|
||||
pnpm docker:down # Parar containers
|
||||
pnpm docker:down:volumes # Parar e remover volumes (⚠️ apaga dados!)
|
||||
pnpm docker:logs # Logs em tempo real
|
||||
pnpm docker:restart # Reiniciar
|
||||
pnpm docker:rebuild # Rebuild completo
|
||||
pnpm docker:up # Sobe app (Docker Hub) + banco em background
|
||||
pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
|
||||
pnpm docker:down # Para e remove os containers
|
||||
pnpm docker:logs # Logs em tempo real (todos os containers)
|
||||
pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root.
|
||||
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
||||
|
||||
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
||||
### Self-hosting (recomendado)
|
||||
|
||||
Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
As credenciais padrão do banco já estão configuradas. Para personalizar (senhas, opcionais), crie um `.env` na mesma pasta antes de subir — veja [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||
|
||||
### Banco remoto (Supabase, Neon, Railway...)
|
||||
|
||||
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
|
||||
|
||||
```bash
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
### Comandos úteis
|
||||
|
||||
```bash
|
||||
docker compose exec app sh # Shell da aplicação
|
||||
docker compose exec app sh # Shell da aplicação
|
||||
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
||||
docker compose ps # Status
|
||||
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
||||
pnpm backup # Backup (ver seção Backup)
|
||||
```
|
||||
|
||||
### Customizando Portas
|
||||
### Customizando portas
|
||||
|
||||
```env
|
||||
APP_PORT=3001 # Padrão: 3000
|
||||
@@ -209,13 +294,142 @@ DB_PORT=5433 # Padrão: 5432
|
||||
|
||||
---
|
||||
|
||||
## 💾 Backup
|
||||
|
||||
O backup é uma rotina de infraestrutura — não é uma tela no app. Ele opera diretamente sobre o banco PostgreSQL e é executado via linha de comando.
|
||||
|
||||
```bash
|
||||
pnpm backup
|
||||
```
|
||||
|
||||
### O que é salvo
|
||||
|
||||
Cada execução gera **3 arquivos** em `backup/`:
|
||||
|
||||
| Arquivo | Conteúdo | Uso |
|
||||
|---|---|---|
|
||||
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom dos schemas `public` + `drizzle` | Restore completo via `pg_restore` |
|
||||
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL compactado dos schemas `public` + `drizzle` | Inspeção manual, portabilidade |
|
||||
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados do schema `public` (sem DDL) | Migração parcial, seed de outro ambiente |
|
||||
|
||||
### Modos de conexão
|
||||
|
||||
Configure `DB_MODE` no topo de `scripts/backup.sh`:
|
||||
|
||||
| Modo | Quando usar | Fonte de dados |
|
||||
|---|---|---|
|
||||
| `remote` (padrão) | Banco em Supabase, Neon, Railway, etc. | `DATABASE_URL` do `.env` |
|
||||
| `docker` | Banco no container local | Container `openmonetis_postgres` |
|
||||
|
||||
### Upload para Google Drive (opcional)
|
||||
|
||||
Se o [rclone](https://rclone.org/) estiver instalado e configurado com um remote chamado `gdrive`, os arquivos são enviados automaticamente para `gdrive:BACKUP OPENMONETIS`. Sem o rclone, o backup funciona normalmente e fica apenas local.
|
||||
|
||||
**Retenção:**
|
||||
- Local: 7 dias
|
||||
- Google Drive: 30 dias
|
||||
|
||||
### Automatizar com cron
|
||||
|
||||
Para rodar o backup automaticamente todo dia às 3h:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /caminho/para/openmonetis && pnpm backup >> /var/log/openmonetis-backup.log 2>&1
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# 1. Zerar o banco
|
||||
docker exec <container-db> psql -U openmonetis -d openmonetis_db \
|
||||
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
|
||||
# 2. Restaurar schema + dados (um comando)
|
||||
docker exec -i <container-db> pg_restore \
|
||||
-U openmonetis -d openmonetis_db \
|
||||
--clean --if-exists --disable-triggers --no-owner --no-privileges \
|
||||
< backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
||||
```
|
||||
|
||||
> `--disable-triggers` é necessário para evitar erros de FK durante o restore (os dados são inseridos fora de ordem). O usuário `openmonetis` tem permissão para isso.
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Storage S3 Compatível
|
||||
|
||||
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
||||
|
||||
### Variáveis
|
||||
|
||||
```env
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
```
|
||||
|
||||
### Compatibilidade
|
||||
|
||||
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](./src/shared/lib/storage/s3-client.ts).
|
||||
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
||||
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
||||
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Logos de Estabelecimentos (Logo.dev)
|
||||
|
||||
O app exibe logos automáticos de marcas na coluna de estabelecimentos nos lançamentos. A integração usa a [Logo.dev](https://www.logo.dev) e é opcional — sem ela, o app exibe as iniciais coloridas normalmente.
|
||||
|
||||
### Variáveis
|
||||
|
||||
```env
|
||||
LOGO_DEV_TOKEN=pk_... # token público (obrigatório para exibir logos)
|
||||
LOGO_DEV_SECRET_KEY=sk_... # chave secreta (obrigatório para o picker de busca)
|
||||
```
|
||||
|
||||
> **Atualizando da v2.4.1 ou anterior:** a variável foi renomeada de `NEXT_PUBLIC_LOGO_DEV_TOKEN` para `LOGO_DEV_TOKEN`. Renomeie no seu `.env` (ou nas variáveis do Coolify/host) e remova o secret homônimo do GitHub Actions — ele não é mais usado. Não há outra etapa de migração.
|
||||
|
||||
### Como configurar
|
||||
|
||||
Ambas as variáveis são lidas em **runtime** pelo servidor Next.js. Não há mais nenhuma etapa no CI nem `--build-arg` no Docker.
|
||||
|
||||
**Self-hosted via Docker Hub (Coolify, Railway, etc.):**
|
||||
|
||||
1. Adicione `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` nas variáveis de ambiente do host
|
||||
2. Reinicie o container — pronto
|
||||
|
||||
**Desenvolvimento local:**
|
||||
|
||||
Adicione as duas no `.env` e rode `pnpm dev`.
|
||||
|
||||
### Como usar
|
||||
|
||||
Após configurado, passe o mouse sobre o avatar de qualquer estabelecimento nos lançamentos — um ícone de lápis aparece. Clique para abrir o picker, busque pelo nome da marca e selecione o logo desejado. O mapeamento fica salvo por usuário no banco.
|
||||
|
||||
### Arquitetura
|
||||
|
||||
O token **nunca chega ao cliente**. O servidor constrói a URL `https://img.logo.dev/{domain}?token=...` nos endpoints `/api/logo/mapping` e `/api/logo/search`, e o cliente apenas consome a URL pronta. Um Context Provider (`LogoDevProvider`) propaga a flag `enabled` para os componentes que decidem se renderizam o picker.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Variáveis de Ambiente
|
||||
|
||||
Copie `.env.example` para `.env` e configure:
|
||||
**Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
|
||||
|
||||
**Perfil 1 (Docker):** não precisa definir `DATABASE_URL` — o compose já configura automaticamente com host `db`. Só defina se usar banco remoto (Supabase, Neon, etc.).
|
||||
|
||||
### Obrigatórias
|
||||
|
||||
```env
|
||||
# Perfil 2 (dev): host "localhost" — o banco roda em container, o app no host
|
||||
# Perfil 1 (Docker): não precisa definir — o compose usa "db" automaticamente
|
||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
@@ -229,6 +443,13 @@ POSTGRES_USER=openmonetis
|
||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||
POSTGRES_DB=openmonetis_db
|
||||
|
||||
# S3 Server (opcional, necessario para anexos)
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
|
||||
# Multi-domínio (landing-only no domínio público)
|
||||
# PUBLIC_DOMAIN=openmonetis.com
|
||||
|
||||
@@ -245,41 +466,90 @@ ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
|
||||
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
|
||||
LOGO_DEV_TOKEN=
|
||||
LOGO_DEV_SECRET_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
|
||||
│ │ │ ├── ui/ # shadcn/ui primitives
|
||||
│ │ │ ├── navigation/ # navbar, sidebar, breadcrumbs
|
||||
│ │ │ ├── brand/ # logos do app
|
||||
│ │ │ ├── widgets/ # widget-card e variantes
|
||||
│ │ │ ├── feedback/ # empty-state, status-dot, payment-success
|
||||
│ │ │ ├── entity-avatar/ # avatares de categoria/estabelecimento
|
||||
│ │ │ ├── month-picker/ # seletor de período
|
||||
│ │ │ ├── logo-picker/ # seletor de logos
|
||||
│ │ │ ├── calculator/ # calculadora de cálculos rápidos
|
||||
│ │ │ ├── skeletons/ # loading skeletons
|
||||
│ │ │ └── providers/ # React context providers
|
||||
│ │ ├── 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)
|
||||
```
|
||||
|
||||
### Estrutura interna de uma feature
|
||||
|
||||
Toda feature em `src/features/<nome>/` segue o mesmo padrão:
|
||||
|
||||
```
|
||||
<feature>/
|
||||
├── actions.ts # Server Actions (entry point — barrel re-export quando há actions/)
|
||||
├── queries.ts # Funções de leitura do banco (entry point)
|
||||
├── actions/ # (opcional) Server Actions divididas por domínio quando o volume cresce
|
||||
├── components/ # Componentes de UI da feature
|
||||
├── hooks/ # React hooks específicos da feature
|
||||
└── lib/ # Helpers, types, sub-queries e constantes
|
||||
```
|
||||
|
||||
A regra é: `actions.ts` e `queries.ts` são as portas de entrada da feature. Tudo que é helper interno fica em `lib/`. Componentes e hooks ficam nas pastas com nome óbvio.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribuindo
|
||||
@@ -291,7 +561,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -319,12 +589,6 @@ Para o texto legal completo, consulte o arquivo [LICENSE](LICENSE) ou visite [cr
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Agradecimentos
|
||||
|
||||
[Next.js](https://nextjs.org/) · [Better Auth](https://better-auth.com/) · [Drizzle ORM](https://orm.drizzle.team/) · [shadcn/ui](https://ui.shadcn.com/) · [Biome](https://biomejs.dev/) · [Vercel](https://vercel.com/)
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido por:** Felipe Coutinho — [@felipegcoutinho](https://github.com/felipegcoutinho)
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { SignupForm } from "@/components/auth/signup-form";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<SignupForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string> {
|
||||
const userAccount = await db.query.account.findFirst({
|
||||
where: eq(schema.account.userId, userId),
|
||||
});
|
||||
return userAccount?.providerId || "credential";
|
||||
}
|
||||
|
||||
export async function fetchUserPreferences(
|
||||
userId: string,
|
||||
): Promise<UserPreferences | null> {
|
||||
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<ApiToken[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<NoteData[]> {
|
||||
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<NoteData[]> {
|
||||
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(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NotesPage } from "@/components/anotacoes/notes-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchAllNotesForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<NotesPage notes={activeNotes} archivedNotes={archivedNotes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import type {
|
||||
CalendarData,
|
||||
CalendarEvent,
|
||||
} from "@/components/calendario/types";
|
||||
import { cartoes, lancamentos } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
mapLancamentosData,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
||||
|
||||
const toDateKey = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const parsePeriod = (period: string) => {
|
||||
const [yearStr, monthStr] = period.split("-");
|
||||
const year = Number.parseInt(yearStr ?? "", 10);
|
||||
const month = Number.parseInt(monthStr ?? "", 10);
|
||||
|
||||
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
|
||||
throw new Error(`Período inválido: ${period}`);
|
||||
}
|
||||
|
||||
return { year, monthIndex: month - 1 };
|
||||
};
|
||||
|
||||
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||
if (day < 1) return 1;
|
||||
if (day > lastDay) return lastDay;
|
||||
return day;
|
||||
};
|
||||
|
||||
const isWithinRange = (value: string | null, start: string, end: string) => {
|
||||
if (!value) return false;
|
||||
return value >= start && value <= end;
|
||||
};
|
||||
|
||||
type FetchCalendarDataParams = {
|
||||
userId: string;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export const fetchCalendarData = async ({
|
||||
userId,
|
||||
period,
|
||||
}: FetchCalendarDataParams): Promise<CalendarData> => {
|
||||
const { year, monthIndex } = parsePeriod(period);
|
||||
const rangeStart = new Date(Date.UTC(year, monthIndex, 1));
|
||||
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
|
||||
const rangeStartKey = toDateKey(rangeStart);
|
||||
const rangeEndKey = toDateKey(rangeEnd);
|
||||
|
||||
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
|
||||
db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
|
||||
or(
|
||||
// Lançamentos cuja data de compra esteja no período do calendário
|
||||
and(
|
||||
gte(lancamentos.purchaseDate, rangeStart),
|
||||
lte(lancamentos.purchaseDate, rangeEnd),
|
||||
),
|
||||
// Boletos cuja data de vencimento esteja no período do calendário
|
||||
and(
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
gte(lancamentos.dueDate, rangeStart),
|
||||
lte(lancamentos.dueDate, rangeEnd),
|
||||
),
|
||||
// Lançamentos de cartão do período (para calcular totais de vencimento)
|
||||
and(
|
||||
eq(lancamentos.period, period),
|
||||
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
),
|
||||
),
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
}),
|
||||
db.query.cartoes.findMany({
|
||||
where: eq(cartoes.userId, userId),
|
||||
}),
|
||||
fetchLancamentoFilterSources(userId),
|
||||
]);
|
||||
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
const cardTotals = new Map<string, number>();
|
||||
for (const item of lancamentosData) {
|
||||
if (
|
||||
!item.cartaoId ||
|
||||
item.period !== period ||
|
||||
item.pagadorRole !== PAGADOR_ROLE_ADMIN
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.abs(item.amount ?? 0);
|
||||
cardTotals.set(
|
||||
item.cartaoId,
|
||||
(cardTotals.get(item.cartaoId) ?? 0) + amount,
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of lancamentosData) {
|
||||
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
||||
const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN;
|
||||
|
||||
// Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin
|
||||
if (isBoleto) {
|
||||
if (
|
||||
isAdminPagador &&
|
||||
item.dueDate &&
|
||||
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
|
||||
) {
|
||||
events.push({
|
||||
id: `${item.id}:boleto`,
|
||||
type: "boleto",
|
||||
date: item.dueDate,
|
||||
lancamento: item,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Para outros tipos de lançamento, exibir na data de compra
|
||||
if (!isAdminPagador) {
|
||||
continue;
|
||||
}
|
||||
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
||||
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
||||
events.push({
|
||||
id: item.id,
|
||||
type: "lancamento",
|
||||
date: purchaseDateKey,
|
||||
lancamento: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exibir vencimentos apenas de cartões com lançamentos do pagador admin
|
||||
for (const card of cardRows) {
|
||||
if (!cardTotals.has(card.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
||||
if (Number.isNaN(dueDayNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
||||
const dueDateKey = toDateKey(
|
||||
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
||||
);
|
||||
|
||||
events.push({
|
||||
id: `${card.id}:cartao`,
|
||||
type: "cartao",
|
||||
date: dueDateKey,
|
||||
card: {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
dueDay: card.dueDay,
|
||||
closingDay: card.closingDay,
|
||||
brand: card.brand ?? null,
|
||||
status: card.status,
|
||||
logo: card.logo ?? null,
|
||||
totalDue: cardTotals.get(card.id) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const typePriority: Record<CalendarEvent["type"], number> = {
|
||||
lancamento: 0,
|
||||
boleto: 1,
|
||||
cartao: 2,
|
||||
};
|
||||
|
||||
events.sort((a, b) => {
|
||||
if (a.date === b.date) {
|
||||
return typePriority[a.type] - typePriority[b.type];
|
||||
}
|
||||
return a.date.localeCompare(b.date);
|
||||
});
|
||||
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const optionSets = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
});
|
||||
|
||||
const estabelecimentos = await getRecentEstablishmentsAction();
|
||||
|
||||
return {
|
||||
events,
|
||||
formOptions: {
|
||||
pagadorOptions: optionSets.pagadorOptions,
|
||||
splitPagadorOptions: optionSets.splitPagadorOptions,
|
||||
defaultPagadorId: optionSets.defaultPagadorId,
|
||||
contaOptions: optionSets.contaOptions,
|
||||
cartaoOptions: optionSets.cartaoOptions,
|
||||
categoriaOptions: optionSets.categoriaOptions,
|
||||
estabelecimentos,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<InvoicePaymentStatus, string> = {
|
||||
[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<ActionResult> {
|
||||
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<number>`
|
||||
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<typeof updatePaymentDateSchema>;
|
||||
|
||||
export async function updatePaymentDateAction(
|
||||
input: UpdatePaymentDateInput,
|
||||
): Promise<ActionResult> {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,202 +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 { CardDialog } from "@/components/cartoes/card-dialog";
|
||||
import type { Card } from "@/components/cartoes/types";
|
||||
import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
|
||||
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 type { Conta } from "@/db/schema";
|
||||
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 { fetchCardData, fetchCardLancamentos, fetchInvoiceData } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ cartaoId: string }>;
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
const { cartaoId } = 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 card = await fetchCardData(userId, cartaoId);
|
||||
|
||||
if (!card) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [
|
||||
filterSources,
|
||||
logoOptions,
|
||||
invoiceData,
|
||||
estabelecimentos,
|
||||
userPreferences,
|
||||
] = await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
||||
getRecentEstablishmentsAction(),
|
||||
fetchUserPreferences(userId),
|
||||
]);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
|
||||
const filters = buildLancamentoWhere({
|
||||
userId,
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
slugMaps,
|
||||
cardId: card.id,
|
||||
});
|
||||
|
||||
const lancamentoRows = await fetchCardLancamentos(filters);
|
||||
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
|
||||
const {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
limitCartaoId: card.id,
|
||||
});
|
||||
|
||||
const accountOptions = filterSources.contaRows.map((conta: Conta) => ({
|
||||
id: conta.id,
|
||||
name: conta.name ?? "Conta",
|
||||
}));
|
||||
|
||||
const contaName =
|
||||
filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId)
|
||||
?.name ?? "Conta";
|
||||
|
||||
const cardDialogData: Card = {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
brand: card.brand ?? "",
|
||||
status: card.status ?? "",
|
||||
closingDay: card.closingDay,
|
||||
dueDay: card.dueDay,
|
||||
note: card.note ?? null,
|
||||
logo: card.logo,
|
||||
limit:
|
||||
card.limit !== null && card.limit !== undefined
|
||||
? Number(card.limit)
|
||||
: null,
|
||||
contaId: card.contaId,
|
||||
contaName,
|
||||
limitInUse: null,
|
||||
limitAvailable: null,
|
||||
};
|
||||
|
||||
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
||||
const limitAmount =
|
||||
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
|
||||
|
||||
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
|
||||
1,
|
||||
)} de ${year}`;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<InvoiceSummaryCard
|
||||
cartaoId={card.id}
|
||||
period={selectedPeriod}
|
||||
cardName={card.name}
|
||||
cardBrand={card.brand ?? null}
|
||||
cardStatus={card.status ?? null}
|
||||
closingDay={card.closingDay}
|
||||
dueDay={card.dueDay}
|
||||
periodLabel={periodLabel}
|
||||
totalAmount={totalAmount}
|
||||
limitAmount={limitAmount}
|
||||
invoiceStatus={invoiceStatus}
|
||||
paymentDate={paymentDate}
|
||||
logo={card.logo}
|
||||
actions={
|
||||
<CardDialog
|
||||
mode="update"
|
||||
card={cardDialogData}
|
||||
logoOptions={logoOptions}
|
||||
accounts={accountOptions}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Editar cartão"
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
defaultCartaoId={card.id}
|
||||
defaultPaymentMethod="Cartão de crédito"
|
||||
lockCartaoSelection
|
||||
lockPaymentMethod
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<number>`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<string, number>();
|
||||
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<number>`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<string, number>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function CartoesLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de cartões */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { CategoryType } from "@/components/categorias/types";
|
||||
import { type Categoria, categorias } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type CategoryData = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchCategoriesForUser(
|
||||
userId: string,
|
||||
): Promise<CategoryData[]> {
|
||||
const categoryRows = await db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
});
|
||||
|
||||
return categoryRows.map((category: Categoria) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
type: category.type as CategoryType,
|
||||
icon: category.icon,
|
||||
}));
|
||||
}
|
||||
@@ -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 (
|
||||
<main>
|
||||
<CategoryHistoryWidget data={data} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { CategoriesPage } from "@/components/categorias/categories-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchCategoriesForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const categories = await fetchCategoriesForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<CategoriesPage categories={categories} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<AccountSummaryData> {
|
||||
const [periodSummary] = await db
|
||||
.select({
|
||||
netAmount: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
incomes: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount}
|
||||
else 0
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
expenses: sql<number>`
|
||||
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<number>`
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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<ResolvedSearchParams>;
|
||||
|
||||
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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
|
||||
<AccountStatementCard
|
||||
accountName={account.name}
|
||||
accountType={account.accountType}
|
||||
status={account.status}
|
||||
periodLabel={periodLabel}
|
||||
openingBalance={openingBalance}
|
||||
currentBalance={currentBalance}
|
||||
totalIncomes={totalIncomes}
|
||||
totalExpenses={totalExpenses}
|
||||
logo={account.logo}
|
||||
actions={
|
||||
<AccountDialog
|
||||
mode="update"
|
||||
account={accountDialogData}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Editar conta"
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={false}
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/lib/actions/helpers";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import {
|
||||
INITIAL_BALANCE_CATEGORY_NAME,
|
||||
INITIAL_BALANCE_CONDITION,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
} from "@/lib/contas/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import {
|
||||
TRANSFER_CATEGORY_NAME,
|
||||
TRANSFER_CONDITION,
|
||||
TRANSFER_ESTABLISHMENT_ENTRADA,
|
||||
TRANSFER_ESTABLISHMENT_SAIDA,
|
||||
TRANSFER_PAYMENT_METHOD,
|
||||
} from "@/lib/transferencias/constants";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import { getTodayInfo } from "@/lib/utils/date";
|
||||
import { normalizeFilePath } from "@/lib/utils/string";
|
||||
|
||||
const accountBaseSchema = z.object({
|
||||
name: z
|
||||
.string({ message: "Informe o nome da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o nome da conta."),
|
||||
accountType: z
|
||||
.string({ message: "Informe o tipo da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o tipo da conta."),
|
||||
status: z
|
||||
.string({ message: "Informe o status da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o status da conta."),
|
||||
note: noteSchema,
|
||||
logo: z
|
||||
.string({ message: "Selecione um logo." })
|
||||
.trim()
|
||||
.min(1, "Selecione um logo."),
|
||||
initialBalance: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um saldo inicial válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value)),
|
||||
excludeFromBalance: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
excludeInitialBalanceFromIncome: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
});
|
||||
|
||||
const createAccountSchema = accountBaseSchema;
|
||||
const updateAccountSchema = accountBaseSchema.extend({
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
const deleteAccountSchema = z.object({
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
|
||||
type AccountCreateInput = z.infer<typeof createAccountSchema>;
|
||||
type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
|
||||
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
|
||||
|
||||
export async function createAccountAction(
|
||||
input: AccountCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const normalizedInitialBalance = Math.abs(data.initialBalance);
|
||||
const hasInitialBalance = normalizedInitialBalance > 0;
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
const [createdAccount] = await tx
|
||||
.insert(contas)
|
||||
.values({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
userId: user.id,
|
||||
})
|
||||
.returning({ id: contas.id, name: contas.name });
|
||||
|
||||
if (!createdAccount) {
|
||||
throw new Error("Não foi possível criar a conta.");
|
||||
}
|
||||
|
||||
if (!hasInitialBalance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [category, adminPagador] = await Promise.all([
|
||||
tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
|
||||
),
|
||||
}),
|
||||
tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!category) {
|
||||
throw new Error(
|
||||
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
|
||||
);
|
||||
}
|
||||
|
||||
const { date, period } = getTodayInfo();
|
||||
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: INITIAL_BALANCE_CONDITION,
|
||||
name: `Saldo inicial - ${createdAccount.name}`,
|
||||
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
note: INITIAL_BALANCE_NOTE,
|
||||
amount: formatDecimalForDbRequired(normalizedInitialBalance),
|
||||
purchaseDate: date,
|
||||
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: createdAccount.id,
|
||||
categoriaId: category.id,
|
||||
pagadorId: adminPagador.id,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta criada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
input: AccountUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const [updated] = await db
|
||||
.update(contas)
|
||||
.set({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
})
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta atualizada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
input: AccountDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteAccountSchema.parse(input);
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(contas)
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning({ id: contas.id });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta removida com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer between accounts
|
||||
const transferSchema = z.object({
|
||||
fromAccountId: uuidSchema("Conta de origem"),
|
||||
toAccountId: uuidSchema("Conta de destino"),
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine((value) => value > 0, "O valor deve ser maior que zero."),
|
||||
date: z.coerce.date({ message: "Informe uma data válida." }),
|
||||
period: z
|
||||
.string({ message: "Informe o período." })
|
||||
.trim()
|
||||
.min(1, "Informe o período."),
|
||||
});
|
||||
|
||||
type TransferInput = z.infer<typeof transferSchema>;
|
||||
|
||||
export async function transferBetweenAccountsAction(
|
||||
input: TransferInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = transferSchema.parse(input);
|
||||
|
||||
// Validate that accounts are different
|
||||
if (data.fromAccountId === data.toAccountId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "A conta de origem e destino devem ser diferentes.",
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a unique transfer ID to link both transactions
|
||||
const transferId = crypto.randomUUID();
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// Verify both accounts exist and belong to the user
|
||||
const [fromAccount, toAccount] = await Promise.all([
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.fromAccountId),
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.toAccountId),
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!fromAccount) {
|
||||
throw new Error("Conta de origem não encontrada.");
|
||||
}
|
||||
|
||||
if (!toAccount) {
|
||||
throw new Error("Conta de destino não encontrada.");
|
||||
}
|
||||
|
||||
// Get the transfer category
|
||||
const transferCategory = await tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, TRANSFER_CATEGORY_NAME),
|
||||
),
|
||||
});
|
||||
|
||||
if (!transferCategory) {
|
||||
throw new Error(
|
||||
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the admin payer
|
||||
const adminPagador = await tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
});
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
|
||||
);
|
||||
}
|
||||
|
||||
const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`;
|
||||
|
||||
// Create outgoing transaction (transfer from source account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: TRANSFER_ESTABLISHMENT_SAIDA,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: transferNote,
|
||||
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: fromAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
|
||||
// Create incoming transaction (transfer to destination account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: TRANSFER_ESTABLISHMENT_ENTRADA,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: transferNote,
|
||||
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: toAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("lancamentos");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Transferência registrada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
@@ -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<number>`
|
||||
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<number>`
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ContasLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de contas */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<UserDashboardPreferences> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { DashboardGridSkeleton } from "@/components/shared/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4">
|
||||
{/* Welcome Banner skeleton */}
|
||||
<Skeleton className="h-[104px] w-full rounded-xl bg-foreground/10" />
|
||||
|
||||
{/* Month Picker skeleton */}
|
||||
<Skeleton className="h-[56px] w-full rounded-xl bg-foreground/10" />
|
||||
|
||||
{/* Dashboard content skeleton (Section Cards + Widget Grid) */}
|
||||
<DashboardGridSkeleton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | 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 (
|
||||
<main className="flex flex-col gap-4">
|
||||
<DashboardWelcome
|
||||
name={user.name}
|
||||
disableMagnetlines={disableMagnetlines}
|
||||
/>
|
||||
<MonthNavigation />
|
||||
<SectionCards metrics={dashboardData.metrics} />
|
||||
<DashboardGridEditable
|
||||
data={dashboardData}
|
||||
period={selectedPeriod}
|
||||
initialPreferences={dashboardWidgets}
|
||||
quickActionOptions={{
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<T> =
|
||||
| { 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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`coalesce(sum(${cartoes.limit}), 0)`,
|
||||
cardCount: sql<number>`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<number>`coalesce(sum(${contas.initialBalance}), 0)`,
|
||||
accountCount: sql<number>`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<number>`coalesce(avg(abs(${lancamentos.amount})), 0)`,
|
||||
transactionCount: sql<number>`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<number, number>();
|
||||
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<number>`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<ActionResult<InsightsResponse>> {
|
||||
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<typeof google>;
|
||||
|
||||
// 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<ActionResult<{ id: string; createdAt: Date }>> {
|
||||
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<ActionResult<void>> {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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<ResolvedSearchParams>;
|
||||
|
||||
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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
<LancamentosPage
|
||||
currentUserId={userId}
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
}>) {
|
||||
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 (
|
||||
<FontProvider
|
||||
systemFont={fontPrefs.systemFont}
|
||||
moneyFont={fontPrefs.moneyFont}
|
||||
>
|
||||
<PrivacyProvider>
|
||||
<AppNavbar
|
||||
user={{ ...session.user, image: session.user.image ?? null }}
|
||||
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
|
||||
preLancamentosCount={preLancamentosCount}
|
||||
notificationsSnapshot={notificationsSnapshot}
|
||||
/>
|
||||
<div className="relative flex flex-1 flex-col pt-16">
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-primary/5 via-transparent to-transparent" />
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PrivacyProvider>
|
||||
</FontProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof createBudgetSchema>;
|
||||
type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>;
|
||||
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>;
|
||||
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<typeof duplicatePreviousMonthSchema>;
|
||||
|
||||
export async function duplicatePreviousMonthBudgetsAction(
|
||||
input: DuplicatePreviousMonthInput,
|
||||
): Promise<ActionResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm";
|
||||
import {
|
||||
categorias,
|
||||
lancamentos,
|
||||
type Orcamento,
|
||||
orcamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
const toNumber = (value: string | number | null | undefined) => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export type BudgetData = {
|
||||
id: string;
|
||||
amount: number;
|
||||
spent: number;
|
||||
period: string;
|
||||
createdAt: string;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchBudgetsForUser(
|
||||
userId: string,
|
||||
selectedPeriod: string,
|
||||
): Promise<{
|
||||
budgets: BudgetData[];
|
||||
categoriesOptions: CategoryOption[];
|
||||
}> {
|
||||
const [budgetRows, categoryRows] = await Promise.all([
|
||||
db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, userId),
|
||||
eq(orcamentos.period, selectedPeriod),
|
||||
),
|
||||
with: {
|
||||
categoria: true,
|
||||
},
|
||||
}),
|
||||
db.query.categorias.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
|
||||
orderBy: asc(categorias.name),
|
||||
}),
|
||||
]);
|
||||
|
||||
const categoryIds = budgetRows
|
||||
.map((budget: Orcamento) => budget.categoriaId)
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
|
||||
let totalsByCategory = new Map<string, number>();
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const totals = await db
|
||||
.select({
|
||||
categoriaId: lancamentos.categoriaId,
|
||||
totalAmount: sum(lancamentos.amount).as("totalAmount"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
inArray(lancamentos.categoriaId, categoryIds),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.categoriaId);
|
||||
|
||||
totalsByCategory = new Map(
|
||||
totals.map(
|
||||
(row: { categoriaId: string | null; totalAmount: string | null }) => [
|
||||
row.categoriaId ?? "",
|
||||
Math.abs(toNumber(row.totalAmount)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const budgets = budgetRows
|
||||
.map((budget: Orcamento) => ({
|
||||
id: budget.id,
|
||||
amount: toNumber(budget.amount),
|
||||
spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0,
|
||||
period: budget.period,
|
||||
createdAt: budget.createdAt.toISOString(),
|
||||
category: budget.categoria
|
||||
? {
|
||||
id: budget.categoria.id,
|
||||
name: budget.categoria.name,
|
||||
icon: budget.categoria.icon,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
|
||||
sensitivity: "base",
|
||||
}),
|
||||
);
|
||||
|
||||
const categoriesOptions = categoryRows.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
}));
|
||||
|
||||
return { budgets, categoriesOptions };
|
||||
}
|
||||
@@ -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<ShareData[]> {
|
||||
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<string, unknown>) => ({
|
||||
...row.lancamento,
|
||||
pagador: row.pagador,
|
||||
conta: row.conta,
|
||||
cartao: row.cartao,
|
||||
categoria: row.categoria,
|
||||
}));
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Info do Pagador (sempre visível) */}
|
||||
<div className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<Skeleton className="size-20 rounded-full bg-foreground/10" />
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Nome + Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="space-y-6 pt-4">
|
||||
<div className="flex gap-2 border-b">
|
||||
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Conteúdo da aba Visão Geral (grid de cards) */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Card de resumo mensal */}
|
||||
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
|
||||
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outros cards */}
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-4">
|
||||
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-3/4 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<ResolvedSearchParams>;
|
||||
|
||||
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<typeof buildOptionSets>;
|
||||
|
||||
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<typeof fetchLancamentoFilterSources>
|
||||
> | null = null;
|
||||
let loggedUserFilterSources: Awaited<
|
||||
ReturnType<typeof fetchLancamentoFilterSources>
|
||||
> | 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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<section>
|
||||
<PagadorInfoCard
|
||||
pagador={pagadorData}
|
||||
selectedPeriod={selectedPeriod}
|
||||
summary={summaryPreview}
|
||||
/>
|
||||
</section>
|
||||
{canEdit && pagadorData.shareCode ? (
|
||||
<PagadorSharingCard
|
||||
pagadorId={pagador.id}
|
||||
shareCode={pagadorData.shareCode}
|
||||
shares={pagadorSharesData}
|
||||
/>
|
||||
) : null}
|
||||
{!canEdit && currentUserShare ? (
|
||||
<PagadorLeaveShareCard
|
||||
shareId={currentUserShare.id}
|
||||
pagadorName={pagadorData.name}
|
||||
createdAt={currentUserShare.createdAt}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="painel" className="space-y-4">
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<PagadorMonthlySummaryCard
|
||||
periodLabel={periodLabel}
|
||||
breakdown={monthlyBreakdown}
|
||||
/>
|
||||
<PagadorHistoryCard data={historyData} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 lg:grid-cols-3">
|
||||
<WidgetCard
|
||||
title="Minhas Faturas"
|
||||
subtitle="Valores por cartão neste período"
|
||||
icon={<RiBankCard2Line className="size-4" />}
|
||||
>
|
||||
<PagadorCardUsageCard items={cardUsage} />
|
||||
</WidgetCard>
|
||||
<WidgetCard
|
||||
title="Boletos"
|
||||
subtitle="Boletos registrados neste período"
|
||||
icon={<RiBarcodeLine className="size-4" />}
|
||||
>
|
||||
<PagadorBoletoCard items={boletoItems} />
|
||||
</WidgetCard>
|
||||
<WidgetCard
|
||||
title="Status de Pagamento"
|
||||
subtitle="Situação das despesas no período"
|
||||
icon={<RiWallet3Line className="size-4" />}
|
||||
>
|
||||
<PagadorPaymentStatusCard data={paymentStatus} />
|
||||
</WidgetCard>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lancamentos">
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={optionSets.pagadorOptions}
|
||||
splitPagadorOptions={optionSets.splitPagadorOptions}
|
||||
defaultPagadorId={pagador.id}
|
||||
contaOptions={optionSets.contaOptions}
|
||||
cartaoOptions={optionSets.cartaoOptions}
|
||||
categoriaOptions={optionSets.categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={optionSets.categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={canEdit}
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
|
||||
importSplitPagadorOptions={
|
||||
loggedUserOptionSets?.splitPagadorOptions
|
||||
}
|
||||
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
|
||||
importContaOptions={loggedUserOptionSets?.contaOptions}
|
||||
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
|
||||
importCategoriaOptions={loggedUserOptionSets?.categoriaOptions}
|
||||
/>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, SelectOption>();
|
||||
const cartaoOptionsMap = new Map<string, SelectOption>();
|
||||
const categoriaOptionsMap = new Map<string, SelectOption>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de pagadores
|
||||
* Layout: Header + Input de compartilhamento + Grid de cards
|
||||
*/
|
||||
export default function PagadoresLoading() {
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Input de código de compartilhamento */}
|
||||
<div className="rounded-2xl border p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de cards de pagadores */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
{/* Avatar + Nome + Badge */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="size-16 rounded-full bg-foreground/10" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<PagadoresPage pagadores={pagadores} avatarOptions={avatarOptions} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<InboxItem[]> {
|
||||
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<InboxItem | null> {
|
||||
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<SelectOption[]> {
|
||||
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<SelectOption[]> {
|
||||
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<Record<string, string>> {
|
||||
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<string, string> = {};
|
||||
|
||||
for (const item of [...userCartoes, ...userContas]) {
|
||||
if (item.logo) {
|
||||
logoMap[item.name.toLowerCase()] = item.logo;
|
||||
}
|
||||
}
|
||||
|
||||
return logoMap;
|
||||
}
|
||||
|
||||
export async function fetchPendingInboxCount(userId: string): Promise<number> {
|
||||
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<string>(new Set(filteredNames)).slice(
|
||||
0,
|
||||
100,
|
||||
);
|
||||
|
||||
return {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<InboxPage
|
||||
pendingItems={pendingItems}
|
||||
processedItems={processedItems}
|
||||
discardedItems={discardedItems}
|
||||
pagadorOptions={dialogData.pagadorOptions}
|
||||
splitPagadorOptions={dialogData.splitPagadorOptions}
|
||||
defaultPagadorId={dialogData.defaultPagadorId}
|
||||
contaOptions={dialogData.contaOptions}
|
||||
cartaoOptions={dialogData.cartaoOptions}
|
||||
categoriaOptions={dialogData.categoriaOptions}
|
||||
estabelecimentos={dialogData.estabelecimentos}
|
||||
appLogoMap={appLogoMap}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex flex-col gap-4 pb-8">
|
||||
<InstallmentAnalysisPage data={data} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Categoria[]> {
|
||||
return db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
orderBy: [asc(categorias.name)],
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { tokensApi } from "@/db/schema";
|
||||
import {
|
||||
extractBearerToken,
|
||||
hashToken,
|
||||
refreshAccessToken,
|
||||
verifyJwt,
|
||||
} from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Extrair refresh token do header
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = extractBearerToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token não fornecido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validar refresh token
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token inválido ou expirado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se token não foi revogado
|
||||
const tokenRecord = await db.query.tokensApi.findFirst({
|
||||
where: and(
|
||||
eq(tokensApi.id, payload.tokenId),
|
||||
eq(tokensApi.userId, payload.sub),
|
||||
isNull(tokensApi.revokedAt),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token revogado ou não encontrado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Gerar novo access token
|
||||
const result = refreshAccessToken(token);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não foi possível renovar o token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar hash do token e último uso
|
||||
await db
|
||||
.update(tokensApi)
|
||||
.set({
|
||||
tokenHash: hashToken(result.accessToken),
|
||||
lastUsedAt: new Date(),
|
||||
lastUsedIp:
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
request.headers.get("x-real-ip"),
|
||||
expiresAt: result.expiresAt,
|
||||
})
|
||||
.where(eq(tokensApi.id, payload.tokenId));
|
||||
|
||||
return NextResponse.json({
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] Error refreshing device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao renovar token" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { tokensApi } from "@/db/schema";
|
||||
import {
|
||||
generateTokenPair,
|
||||
getTokenPrefix,
|
||||
hashToken,
|
||||
} from "@/lib/auth/api-token";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
|
||||
deviceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Verificar autenticação via sessão web
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validar body
|
||||
const body = await request.json();
|
||||
const { name, deviceId } = createTokenSchema.parse(body);
|
||||
|
||||
// Gerar par de tokens
|
||||
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
|
||||
session.user.id,
|
||||
deviceId,
|
||||
);
|
||||
|
||||
// Salvar hash do token no banco
|
||||
await db.insert(tokensApi).values({
|
||||
id: tokenId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
tokenHash: hashToken(accessToken),
|
||||
tokenPrefix: getTokenPrefix(accessToken),
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Retornar tokens (mostrados apenas uma vez)
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenId,
|
||||
name,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
message:
|
||||
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[API] Error creating device token:", error);
|
||||
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { tokensApi } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Verificar autenticação via sessão web
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Buscar tokens ativos do usuário
|
||||
const activeTokens = await db
|
||||
.select({
|
||||
id: tokensApi.id,
|
||||
name: tokensApi.name,
|
||||
tokenPrefix: tokensApi.tokenPrefix,
|
||||
lastUsedAt: tokensApi.lastUsedAt,
|
||||
lastUsedIp: tokensApi.lastUsedIp,
|
||||
expiresAt: tokensApi.expiresAt,
|
||||
createdAt: tokensApi.createdAt,
|
||||
})
|
||||
.from(tokensApi)
|
||||
.where(
|
||||
and(eq(tokensApi.userId, session.user.id), isNull(tokensApi.revokedAt)),
|
||||
)
|
||||
.orderBy(desc(tokensApi.createdAt));
|
||||
|
||||
return NextResponse.json({ tokens: activeTokens });
|
||||
} catch (error) {
|
||||
console.error("[API] Error listing device tokens:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao listar tokens" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.3 KiB |
BIN
app/icon1.png
BIN
app/icon1.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,38 +0,0 @@
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { allFontVariables } from "@/public/fonts/font_index";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "OpenMonetis | Suas finanças, do seu jeito",
|
||||
template: "%s | OpenMonetis",
|
||||
},
|
||||
description:
|
||||
"Controle suas finanças pessoais de forma simples e transparente.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt-BR" className={allFontVariables} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||
</head>
|
||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
||||
: "https://openmonetis.com";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: [
|
||||
"/dashboard",
|
||||
"/lancamentos",
|
||||
"/contas",
|
||||
"/cartoes",
|
||||
"/categorias",
|
||||
"/orcamentos",
|
||||
"/pagadores",
|
||||
"/anotacoes",
|
||||
"/insights",
|
||||
"/calendario",
|
||||
"/consultor",
|
||||
"/ajustes",
|
||||
"/relatorios",
|
||||
"/pre-lancamentos",
|
||||
"/login",
|
||||
"/api/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${BASE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { ChangelogVersion } from "@/lib/changelog/parse-changelog";
|
||||
|
||||
/** Converte "[texto](url)" em link; texto simples fica como está */
|
||||
function parseContributorLine(content: string) {
|
||||
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
|
||||
if (linkMatch) {
|
||||
return { label: linkMatch[1], url: linkMatch[2] };
|
||||
}
|
||||
return { label: content, url: null };
|
||||
}
|
||||
|
||||
const sectionBadgeVariant: Record<
|
||||
string,
|
||||
"success" | "info" | "destructive" | "secondary"
|
||||
> = {
|
||||
Adicionado: "success",
|
||||
Alterado: "info",
|
||||
Corrigido: "destructive",
|
||||
Removido: "secondary",
|
||||
};
|
||||
|
||||
function getSectionVariant(type: string) {
|
||||
return sectionBadgeVariant[type] ?? "secondary";
|
||||
}
|
||||
|
||||
export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<Card key={version.version} className="p-6">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h3 className="text-lg font-bold">v{version.version}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{version.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{version.sections.map((section) => (
|
||||
<div key={section.type}>
|
||||
<Badge
|
||||
variant={getSectionVariant(section.type)}
|
||||
className="mb-2"
|
||||
>
|
||||
{section.type}
|
||||
</Badge>
|
||||
<ul className="space-y-1.5 text-muted-foreground">
|
||||
{section.items.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="text-primary select-none">•</span>
|
||||
<span className="text-sm">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{version.contributor && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Contribuições: {(() => {
|
||||
const { label, url } = parseContributorLine(
|
||||
version.contributor,
|
||||
);
|
||||
if (url) {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAndroidLine,
|
||||
RiDownload2Line,
|
||||
RiExternalLinkLine,
|
||||
RiNotification3Line,
|
||||
RiQrCodeLine,
|
||||
RiShieldCheckLine,
|
||||
} from "@remixicon/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ApiTokensForm } from "./api-tokens-form";
|
||||
|
||||
interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
lastUsedAt: Date | null;
|
||||
lastUsedIp: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
interface CompanionTabProps {
|
||||
tokens: ApiToken[];
|
||||
}
|
||||
|
||||
const steps: {
|
||||
icon: typeof RiDownload2Line;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
icon: RiDownload2Line,
|
||||
title: "Instale o app",
|
||||
description: (
|
||||
<>
|
||||
Baixe o APK no{" "}
|
||||
<a
|
||||
href="https://github.com/felipegcoutinho/openmonetis-companion"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: RiQrCodeLine,
|
||||
title: "Gere um token",
|
||||
description: "Crie um token abaixo para autenticar.",
|
||||
},
|
||||
{
|
||||
icon: RiNotification3Line,
|
||||
title: "Configure permissões",
|
||||
description: "Conceda acesso às notificações.",
|
||||
},
|
||||
{
|
||||
icon: RiShieldCheckLine,
|
||||
title: "Pronto!",
|
||||
description: "Notificações serão enviadas ao OpenMonetis.",
|
||||
},
|
||||
];
|
||||
|
||||
export function CompanionTab({ tokens }: CompanionTabProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-lg font-bold">OpenMonetis Companion</h2>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
||||
<RiAndroidLine className="h-3 w-3" />
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Capture notificações de transações dos seus apps de banco (Nubank,
|
||||
Itaú, Bradesco, Inter, C6 e outros) e envie para sua caixa de
|
||||
entrada.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.title} className="flex items-start gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<step.icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
{index + 1}. {step.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Devices */}
|
||||
<ApiTokensForm tokens={tokens} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
|
||||
<li>Lançamentos, orçamentos e anotações</li>
|
||||
<li>Contas, cartões e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
<li className="font-bold">
|
||||
Resumindo tudo, sua conta será permanentemente removida
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Você tem certeza?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
|
||||
sua conta e remover seus dados de nossos servidores.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation">
|
||||
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmation"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="DELETAR"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCloseModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending || confirmation !== "DELETAR"}
|
||||
>
|
||||
{isPending ? "Deletando..." : "Deletar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { RiDragMove2Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { useFont } from "@/components/font-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||
LANCAMENTOS_COLUMN_LABELS,
|
||||
} from "@/lib/lancamentos/column-order";
|
||||
import { FONT_OPTIONS } from "@/public/fonts/font_index";
|
||||
|
||||
interface PreferencesFormProps {
|
||||
disableMagnetlines: boolean;
|
||||
extratoNoteAsColumn: boolean;
|
||||
lancamentosColumnOrder: string[] | null;
|
||||
systemFont: string;
|
||||
moneyFont: string;
|
||||
}
|
||||
|
||||
function SortableColumnItem({ id }: { id: string }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const label = LANCAMENTOS_COLUMN_LABELS[id] ?? id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex cursor-grab active:cursor-grabbing items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm touch-none select-none ${
|
||||
isDragging ? "z-10 opacity-90 shadow-md" : ""
|
||||
}`}
|
||||
aria-label={`Arrastar ${label}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<RiDragMove2Line
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreferencesForm({
|
||||
disableMagnetlines,
|
||||
extratoNoteAsColumn: initialExtratoNoteAsColumn,
|
||||
lancamentosColumnOrder: initialColumnOrder,
|
||||
systemFont: initialSystemFont,
|
||||
moneyFont: initialMoneyFont,
|
||||
}: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||
useState(disableMagnetlines);
|
||||
const [extratoNoteAsColumn, setExtratoNoteAsColumn] = useState(
|
||||
initialExtratoNoteAsColumn,
|
||||
);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
initialColumnOrder && initialColumnOrder.length > 0
|
||||
? initialColumnOrder
|
||||
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||
);
|
||||
const [selectedSystemFont, setSelectedSystemFont] =
|
||||
useState(initialSystemFont);
|
||||
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const handleColumnDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setColumnOrder((items) => {
|
||||
const oldIndex = items.indexOf(active.id as string);
|
||||
const newIndex = items.indexOf(over.id as string);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fontCtx = useFont();
|
||||
|
||||
// Live preview: update CSS vars when font selection changes
|
||||
useEffect(() => {
|
||||
fontCtx.setSystemFont(selectedSystemFont);
|
||||
}, [selectedSystemFont, fontCtx.setSystemFont]);
|
||||
|
||||
useEffect(() => {
|
||||
fontCtx.setMoneyFont(selectedMoneyFont);
|
||||
}, [selectedMoneyFont, fontCtx.setMoneyFont]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updatePreferencesAction({
|
||||
disableMagnetlines: magnetlinesDisabled,
|
||||
extratoNoteAsColumn,
|
||||
lancamentosColumnOrder: columnOrder,
|
||||
systemFont: selectedSystemFont,
|
||||
moneyFont: selectedMoneyFont,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
||||
{/* Seção 1: Tipografia */}
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Tipografia</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Personalize as fontes usadas na interface e nos valores monetários.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fonte do sistema */}
|
||||
<div className="space-y-2 max-w-md">
|
||||
<Label htmlFor="system-font">Fonte do sistema</Label>
|
||||
<Select
|
||||
value={selectedSystemFont}
|
||||
onValueChange={setSelectedSystemFont}
|
||||
>
|
||||
<SelectTrigger id="system-font">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.key} value={opt.key}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: opt.variable,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Fonte de valores */}
|
||||
<div className="space-y-2 max-w-md">
|
||||
<Label htmlFor="money-font">Fonte de valores</Label>
|
||||
<Select
|
||||
value={selectedMoneyFont}
|
||||
onValueChange={setSelectedMoneyFont}
|
||||
>
|
||||
<SelectTrigger id="money-font">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.key} value={opt.key}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: opt.variable,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-b" />
|
||||
|
||||
{/* Seção: Extrato / Lançamentos */}
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Extrato e lançamentos</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Como exibir anotações e a ordem das colunas na tabela de
|
||||
movimentações.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="extrato-note-column" className="text-base">
|
||||
Anotações em coluna
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quando ativo, as anotações aparecem em uma coluna na tabela.
|
||||
Quando desativado, aparecem em um balão ao passar o mouse no
|
||||
ícone.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="extrato-note-column"
|
||||
checked={extratoNoteAsColumn}
|
||||
onCheckedChange={setExtratoNoteAsColumn}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-w-md">
|
||||
<Label className="text-base">Ordem das colunas</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Arraste os itens para definir a ordem em que as colunas aparecem na
|
||||
tabela do extrato e dos lançamentos.
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnOrder}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{columnOrder.map((id) => (
|
||||
<SortableColumnItem key={id} id={id} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-b" />
|
||||
|
||||
{/* Seção: Dashboard */}
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Dashboard</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Opções que afetam a experiência no painel principal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="magnetlines" className="text-base">
|
||||
Desabilitar Magnetlines
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove o recurso de linhas magnéticas do sistema.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="magnetlines"
|
||||
checked={magnetlinesDisabled}
|
||||
onCheckedChange={setMagnetlinesDisabled}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Salvando..." : "Salvar preferências"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title }: AuthHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5")}>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import MagnetLines from "../magnet-lines";
|
||||
|
||||
function AuthSidebar() {
|
||||
return (
|
||||
<div className="relative hidden flex-col overflow-hidden bg-welcome-banner text-welcome-banner-foreground md:flex">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
|
||||
<MagnetLines
|
||||
rows={10}
|
||||
columns={16}
|
||||
containerSize="120%"
|
||||
lineColor="currentColor"
|
||||
lineWidth="0.35vmin"
|
||||
lineHeight="5vmin"
|
||||
baseAngle={-4}
|
||||
className="text-welcome-banner-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 flex-col justify-between p-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
Controle suas finanças com clareza e foco diário.
|
||||
</h2>
|
||||
<p className="text-sm opacity-90">
|
||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
||||
um painel inteligente feito para o seu dia a dia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthSidebar;
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Logo className="mb-2" />
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
|
||||
<form
|
||||
className="flex flex-col gap-6 p-6 md:p-8"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<AuthHeader title="Criar sua conta" />
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Digite seu nome"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
placeholder="Crie uma senha forte"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={
|
||||
!!error ||
|
||||
(password.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
maxLength={23}
|
||||
/>
|
||||
{password.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
loadingEmail ||
|
||||
loadingGoogle ||
|
||||
(password.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingEmail ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Criar conta"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
|
||||
Ou continue com
|
||||
</FieldSeparator>
|
||||
|
||||
<Field>
|
||||
<GoogleAuthButton
|
||||
onClick={handleGoogle}
|
||||
loading={loadingGoogle}
|
||||
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
|
||||
text="Continuar com Google"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
Já tem uma conta?{" "}
|
||||
<a href="/login" className="underline underline-offset-4">
|
||||
Entrar
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<AuthSidebar />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
<a href="/" className="underline underline-offset-4">
|
||||
Voltar para o site
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
|
||||
import type { CalendarEvent } from "@/components/calendario/types";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
const LEGEND_ITEMS: Array<{
|
||||
type?: CalendarEvent["type"];
|
||||
label: string;
|
||||
dotColor?: string;
|
||||
}> = [
|
||||
{ type: "lancamento", label: "Lançamentos" },
|
||||
{ type: "boleto", label: "Boleto com vencimento" },
|
||||
{ type: "cartao", label: "Vencimento de cartão" },
|
||||
{ label: "Pagamento fatura", dotColor: "bg-success" },
|
||||
];
|
||||
|
||||
export function CalendarLegend() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 rounded-sm border border-border/60 bg-muted/20 p-2 text-xs font-medium text-muted-foreground">
|
||||
{LEGEND_ITEMS.map((item, index) => {
|
||||
const dotColor =
|
||||
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
|
||||
return (
|
||||
<span key={item.type || index} className="flex items-center gap-2">
|
||||
<span className={cn("size-3 rounded-full", dotColor)} />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
|
||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
type DayCellProps = {
|
||||
day: CalendarDay;
|
||||
onSelect: (day: CalendarDay) => void;
|
||||
onCreate: (day: CalendarDay) => void;
|
||||
};
|
||||
|
||||
export const EVENT_TYPE_STYLES: Record<
|
||||
CalendarEvent["type"],
|
||||
{ wrapper: string; dot: string; accent?: string }
|
||||
> = {
|
||||
lancamento: {
|
||||
wrapper:
|
||||
"bg-warning/10 text-warning dark:bg-warning/5 dark:text-warning border-l-4 border-warning",
|
||||
dot: "bg-warning",
|
||||
},
|
||||
boleto: {
|
||||
wrapper:
|
||||
"bg-info/10 text-info dark:bg-info/5 dark:text-info border-l-4 border-info",
|
||||
dot: "bg-info",
|
||||
},
|
||||
cartao: {
|
||||
wrapper:
|
||||
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
|
||||
dot: "bg-violet-600",
|
||||
},
|
||||
};
|
||||
|
||||
const eventStyles = EVENT_TYPE_STYLES;
|
||||
|
||||
const formatCurrencyValue = (value: number | null | undefined) =>
|
||||
currencyFormatter.format(Math.abs(value ?? 0));
|
||||
|
||||
const formatAmount = (event: Extract<CalendarEvent, { type: "lancamento" }>) =>
|
||||
formatCurrencyValue(event.lancamento.amount);
|
||||
|
||||
const buildEventLabel = (event: CalendarEvent) => {
|
||||
switch (event.type) {
|
||||
case "lancamento": {
|
||||
return event.lancamento.name;
|
||||
}
|
||||
case "boleto": {
|
||||
return event.lancamento.name;
|
||||
}
|
||||
case "cartao": {
|
||||
return event.card.name;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const buildEventComplement = (event: CalendarEvent) => {
|
||||
switch (event.type) {
|
||||
case "lancamento": {
|
||||
return formatAmount(event);
|
||||
}
|
||||
case "boleto": {
|
||||
return formatCurrencyValue(event.lancamento.amount);
|
||||
}
|
||||
case "cartao": {
|
||||
if (event.card.totalDue !== null) {
|
||||
return formatCurrencyValue(event.card.totalDue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isPagamentoFatura = (event: CalendarEvent) => {
|
||||
return (
|
||||
event.type === "lancamento" &&
|
||||
event.lancamento.name.startsWith("Pagamento fatura -")
|
||||
);
|
||||
};
|
||||
|
||||
const getEventStyle = (event: CalendarEvent) => {
|
||||
if (isPagamentoFatura(event)) {
|
||||
return {
|
||||
wrapper:
|
||||
"bg-success/10 text-success dark:bg-success/5 dark:text-success border-l-4 border-success",
|
||||
dot: "bg-success",
|
||||
};
|
||||
}
|
||||
return eventStyles[event.type];
|
||||
};
|
||||
|
||||
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
const complement = buildEventComplement(event);
|
||||
const label = buildEventLabel(event);
|
||||
const style = getEventStyle(event);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
|
||||
style.wrapper,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{complement ? (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
|
||||
>
|
||||
{complement}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
||||
const previewEvents = day.events.slice(0, 3);
|
||||
const hasOverflow = day.events.length > 3;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " " || event.key === "Space") {
|
||||
event.preventDefault();
|
||||
onSelect(day);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCreate(day);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(day)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
|
||||
!day.isCurrentMonth && "opacity-60",
|
||||
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold leading-none",
|
||||
day.isToday
|
||||
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
|
||||
: "text-foreground/90",
|
||||
)}
|
||||
>
|
||||
{day.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateClick}
|
||||
className="flex size-6 items-center justify-center rounded-full border bg-muted text-muted-foreground transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
aria-label={`Criar lançamento em ${day.date}`}
|
||||
>
|
||||
<RiAddLine className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
{previewEvents.map((event) => (
|
||||
<DayEventPreview key={event.id} event={event} />
|
||||
))}
|
||||
|
||||
{hasOverflow ? (
|
||||
<span className="text-xs font-medium text-primary/80">
|
||||
+ ver mais
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
|
||||
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { friendlyDate, parseLocalDateString } from "@/lib/utils/date";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import MoneyValues from "../money-values";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
type EventModalProps = {
|
||||
open: boolean;
|
||||
day: CalendarDay | null;
|
||||
onClose: () => void;
|
||||
onCreate: (date: string) => void;
|
||||
};
|
||||
|
||||
const EventCard = ({
|
||||
children,
|
||||
type,
|
||||
isPagamentoFatura = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
type: CalendarEvent["type"];
|
||||
isPagamentoFatura?: boolean;
|
||||
}) => {
|
||||
const style = isPagamentoFatura
|
||||
? { dot: "bg-success" }
|
||||
: EVENT_TYPE_STYLES[type];
|
||||
return (
|
||||
<Card className="flex flex-row gap-2 p-3 mb-1">
|
||||
<span
|
||||
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLancamento = (
|
||||
event: Extract<CalendarEvent, { type: "lancamento" }>,
|
||||
) => {
|
||||
const isReceita = event.lancamento.transactionType === "Receita";
|
||||
const isPagamentoFatura =
|
||||
event.lancamento.name.startsWith("Pagamento fatura -");
|
||||
|
||||
return (
|
||||
<EventCard type="lancamento" isPagamentoFatura={isPagamentoFatura}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-sm font-semibold leading-tight ${
|
||||
isPagamentoFatura && "text-success"
|
||||
}`}
|
||||
>
|
||||
{event.lancamento.name}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Badge variant={"outline"}>{event.lancamento.condition}</Badge>
|
||||
<Badge variant={"outline"}>{event.lancamento.paymentMethod}</Badge>
|
||||
<Badge variant={"outline"}>{event.lancamento.categoriaName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold whitespace-nowrap",
|
||||
isReceita ? "text-success" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<MoneyValues
|
||||
showPositiveSign
|
||||
className="text-base"
|
||||
amount={event.lancamento.amount}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</EventCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
const isPaid = Boolean(event.lancamento.isSettled);
|
||||
const dueDate = event.lancamento.dueDate;
|
||||
const formattedDueDate = dueDate
|
||||
? new Intl.DateTimeFormat("pt-BR").format(new Date(dueDate))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<EventCard type="boleto">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
{event.lancamento.name}
|
||||
</span>
|
||||
|
||||
{formattedDueDate && (
|
||||
<span className="text-xs text-muted-foreground leading-tight">
|
||||
Vence em {formattedDueDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
<MoneyValues amount={event.lancamento.amount} />
|
||||
</span>
|
||||
</div>
|
||||
</EventCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCard = (event: Extract<CalendarEvent, { type: "cartao" }>) => (
|
||||
<EventCard type="cartao">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
Vencimento Fatura - {event.card.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge variant={"outline"}>{event.card.status ?? "Fatura"}</Badge>
|
||||
</div>
|
||||
{event.card.totalDue !== null ? (
|
||||
<span className="font-semibold">
|
||||
<MoneyValues amount={event.card.totalDue} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</EventCard>
|
||||
);
|
||||
|
||||
const renderEvent = (event: CalendarEvent) => {
|
||||
switch (event.type) {
|
||||
case "lancamento":
|
||||
return renderLancamento(event);
|
||||
case "boleto":
|
||||
return renderBoleto(event);
|
||||
case "cartao":
|
||||
return renderCard(event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
||||
const formattedDate = !day
|
||||
? ""
|
||||
: friendlyDate(parseLocalDateString(day.date));
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!day) return;
|
||||
onClose();
|
||||
onCreate(day.date);
|
||||
};
|
||||
|
||||
const description = day?.events.length
|
||||
? "Confira os lançamentos e vencimentos cadastrados para este dia."
|
||||
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formattedDate}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
|
||||
{day?.events.length ? (
|
||||
day.events.map((event) => (
|
||||
<div key={event.id}>{renderEvent(event)}</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum lançamento ou vencimento registrado. Clique em{" "}
|
||||
<span className="font-medium text-primary">Novo lançamento</span>{" "}
|
||||
para começar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!day}>
|
||||
Novo lançamento
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiChat3Line,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import MoneyValues from "../money-values";
|
||||
|
||||
interface CardItemProps {
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: number | null;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
contaName: string;
|
||||
logo?: string | null;
|
||||
note?: string | null;
|
||||
onEdit?: () => void;
|
||||
onInvoice?: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const BRAND_ASSETS: Record<string, string> = {
|
||||
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<keyof typeof BRAND_ASSETS>
|
||||
).find((entry) => normalized.includes(entry));
|
||||
|
||||
return match ? BRAND_ASSETS[match] : null;
|
||||
};
|
||||
|
||||
const formatDay = (value: string) => value.padStart(2, "0");
|
||||
|
||||
export function CardItem({
|
||||
name,
|
||||
brand,
|
||||
status,
|
||||
closingDay,
|
||||
dueDay,
|
||||
limit,
|
||||
limitInUse,
|
||||
limitAvailable,
|
||||
contaName: _contaName,
|
||||
logo,
|
||||
note,
|
||||
onEdit,
|
||||
onInvoice,
|
||||
onRemove,
|
||||
}: CardItemProps) {
|
||||
void _contaName;
|
||||
|
||||
const limitTotal = limit ?? null;
|
||||
const used =
|
||||
limitInUse ??
|
||||
(limitTotal !== null && limitAvailable !== null
|
||||
? Math.max(limitTotal - limitAvailable, 0)
|
||||
: limitTotal !== null
|
||||
? 0
|
||||
: null);
|
||||
|
||||
const available =
|
||||
limitAvailable ??
|
||||
(limitTotal !== null && used !== null
|
||||
? Math.max(limitTotal - used, 0)
|
||||
: null);
|
||||
|
||||
const usagePercent =
|
||||
limitTotal && limitTotal > 0 && used !== null
|
||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
||||
: 0;
|
||||
|
||||
const logoPath = useMemo(() => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
logo.startsWith("http://") ||
|
||||
logo.startsWith("https://") ||
|
||||
logo.startsWith("data:")
|
||||
) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
}, [logo]);
|
||||
|
||||
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
|
||||
|
||||
const isInactive = useMemo(
|
||||
() => status?.toLowerCase() === "inativo",
|
||||
[status],
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (limitTotal === null) return null;
|
||||
|
||||
return [
|
||||
{ label: "Limite Total", value: limitTotal },
|
||||
{ label: "Em uso", value: used },
|
||||
{ label: "Disponível", value: available },
|
||||
];
|
||||
}, [available, limitTotal, used]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "ver fatura",
|
||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||
onClick: onInvoice,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
className: "text-destructive",
|
||||
},
|
||||
],
|
||||
[onEdit, onInvoice, onRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col p-6 w-full">
|
||||
<CardHeader className="space-y-2 px-0 pb-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{logoPath ? (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo do cartão ${name}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
{name}
|
||||
</h3>
|
||||
{note ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
aria-label="Observações do cartão"
|
||||
>
|
||||
<RiChat3Line className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{note}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<span className="text-xs tracking-wide text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{brandAsset ? (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${brand}`}
|
||||
width={36}
|
||||
height={36}
|
||||
className={cn(
|
||||
"h-5 w-auto rounded",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-y border-dashed py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||
<span>
|
||||
Fecha dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(closingDay)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Vence dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(dueDay)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-5 px-0">
|
||||
{metrics ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[0].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[0].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[1].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[2].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[2].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={usagePercent} className="h-3" />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há limite registrado para este cartão.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
|
||||
{actions.map(({ label, icon, onClick, className }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="category-name">Nome</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Alimentação"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="category-type">Tipo da categoria</Label>
|
||||
<Select
|
||||
value={values.type}
|
||||
onValueChange={(value) => onChange("type", value)}
|
||||
>
|
||||
<SelectTrigger id="category-type" className="w-full">
|
||||
<SelectValue placeholder="Selecione o tipo">
|
||||
{values.type && (
|
||||
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ícone</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
|
||||
{values.icon ? (
|
||||
<CategoryIcon name={values.icon} className="size-7" />
|
||||
) : (
|
||||
<RiMoreLine className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
Selecionar ícone
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[480px] p-3" align="start">
|
||||
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
|
||||
{iconOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleIconSelect(option.value)}
|
||||
className={cn(
|
||||
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
|
||||
values.icon === option.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
title={option.label}
|
||||
>
|
||||
<CategoryIcon name={option.value} className="size-6" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Escolha um ícone que represente melhor esta categoria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, RemixiconComponentType | undefined>;
|
||||
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 (
|
||||
<span className={cn("text-xs text-muted-foreground", className)}>
|
||||
{name ?? "Categoria"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <IconComponent className={cn("size-5", className)} aria-hidden />;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiInformationLine,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import MoneyValues from "../money-values";
|
||||
import { Card, CardContent, CardFooter } from "../ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface AccountCardProps {
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
balance: number;
|
||||
status?: string;
|
||||
icon?: React.ReactNode;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
onViewStatement?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
onTransfer?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AccountCard({
|
||||
accountName,
|
||||
accountType,
|
||||
balance,
|
||||
status,
|
||||
icon,
|
||||
excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome,
|
||||
onViewStatement,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onTransfer,
|
||||
className,
|
||||
}: AccountCardProps) {
|
||||
const isInactive = status?.toLowerCase() === "inativa";
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "extrato",
|
||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||
onClick: onViewStatement,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "transferir",
|
||||
icon: <RiArrowLeftRightLine className="size-4" aria-hidden />,
|
||||
onClick: onTransfer,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
].filter((action) => typeof action.onClick === "function");
|
||||
|
||||
return (
|
||||
<Card className={cn("h-full w-full gap-0", className)}>
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{excludeFromBalance && (
|
||||
<p className="text-xs">
|
||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
||||
não é incluída no cálculo do saldo total geral.
|
||||
</p>
|
||||
)}
|
||||
{excludeInitialBalanceFromIncome && (
|
||||
<p className="text-xs">
|
||||
<strong>
|
||||
Saldo inicial desconsiderado das receitas:
|
||||
</strong>{" "}
|
||||
O saldo inicial desta conta não é contabilizado como
|
||||
receita nas métricas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{actions.length > 0 ? (
|
||||
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
|
||||
{actions.map(({ label, icon, onClick, variant }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
variant === "destructive" ? "text-destructive" : "text-primary",
|
||||
)}
|
||||
aria-label={`${label} conta`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DotIcon from "@/components/dot-icon";
|
||||
|
||||
export function StatusSelectContent({ label }: { label: string }) {
|
||||
const isActive = label === "Ativa";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<DotIcon
|
||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="border">
|
||||
<CardHeader className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{logoPath ? (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo da conta ${accountName}`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold text-foreground">
|
||||
{accountName}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Extrato de {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
|
||||
{/* Composição do Saldo */}
|
||||
<div className="space-y-3">
|
||||
<DetailItem
|
||||
label="Saldo no início do período"
|
||||
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
|
||||
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<DetailItem
|
||||
label="Entradas"
|
||||
value={
|
||||
<span className="font-medium text-success">
|
||||
{formatCurrency(totalIncomes)}
|
||||
</span>
|
||||
}
|
||||
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
||||
/>
|
||||
<DetailItem
|
||||
label="Saídas"
|
||||
value={
|
||||
<span className="font-medium text-destructive">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</span>
|
||||
}
|
||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
||||
/>
|
||||
|
||||
<DetailItem
|
||||
label="Resultado do período"
|
||||
value={
|
||||
<MoneyValues
|
||||
amount={totalIncomes - totalExpenses}
|
||||
className={cn(
|
||||
"font-semibold text-xl",
|
||||
totalIncomes - totalExpenses >= 0
|
||||
? "text-success"
|
||||
: "text-destructive",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Saldo Atual - Destaque Principal */}
|
||||
<DetailItem
|
||||
label="Saldo ao final do período"
|
||||
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
|
||||
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Informações da Conta */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
|
||||
<DetailItem
|
||||
label="Tipo da conta"
|
||||
value={accountType}
|
||||
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
|
||||
/>
|
||||
<DetailItem
|
||||
label="Status da conta"
|
||||
value={
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant={getAccountStatusBadgeVariant(status)}
|
||||
className="text-xs"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string;
|
||||
value: DetailValue;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
|
||||
{label}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-xs text-xs"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="text-base text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalState, setModalState] = useState<ModalState>("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 (
|
||||
<>
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{items.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum boleto cadastrado para o período selecionado"
|
||||
description="Cadastre boletos para monitorar os pagamentos aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{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 (
|
||||
<li
|
||||
key={boleto.id}
|
||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 py-2">
|
||||
<EstabelecimentoLogo name={boleto.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{boleto.name}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
boleto.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={boleto.amount} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={boleto.isSettled}
|
||||
onClick={() => handleOpenModal(boleto.id)}
|
||||
>
|
||||
{boleto.isSettled ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : isOverdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
"Pagar"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
resetModalState();
|
||||
return;
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
resetModalState();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento registrado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status do boleto para pago. Em instantes ele
|
||||
aparecerá como baixado no histórico.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<ModalFooter className="sm:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={resetModalState}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme os dados para registrar o pagamento. Você poderá
|
||||
editar o lançamento depois, se necessário.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedBoleto ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<RiBarcodeFill className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{selectedBoleto.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedBoletoDueLabel ? (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedBoletoDueLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor do Boleto
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={selectedBoleto.amount}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(
|
||||
selectedBoleto.isSettled ? "Pago" : "Pendente",
|
||||
)}
|
||||
>
|
||||
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModalFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetModalState}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmPayment}
|
||||
disabled={
|
||||
isProcessing || !selectedBoleto || selectedBoleto.isSettled
|
||||
}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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<string, number>;
|
||||
}>;
|
||||
}, [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<string, number | string> = { 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 (
|
||||
<Card className="h-auto">
|
||||
<CardContent className="space-y-2.5">
|
||||
<div className="space-y-2">
|
||||
{selectedCategoryDetails.length > 0 && (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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 (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4" style={{ color }} />
|
||||
) : (
|
||||
<div
|
||||
className="size-3 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-foreground">{category.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => handleRemoveCategory(category.id)}
|
||||
>
|
||||
<RiCloseLine className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{selectedCategories.length}/5 selecionadas
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCategories.length < 5 && availableCategories.length > 0 && (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between hover:scale-none"
|
||||
>
|
||||
Selecionar categorias
|
||||
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Pesquisar categoria..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||
|
||||
{despesaCategories.length > 0 && (
|
||||
<CommandGroup heading="Despesas">
|
||||
{despesaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-destructive" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-destructive" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{receitaCategories.length > 0 && (
|
||||
<CommandGroup heading="Receitas">
|
||||
{receitaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-success" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-success" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="h-[450px] flex items-center justify-center">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="h-[450px] w-full">
|
||||
<AreaChart
|
||||
data={filteredChartData}
|
||||
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
{filteredCategories.map((category) => (
|
||||
<linearGradient
|
||||
key={`gradient-${category.id}`}
|
||||
id={`gradient-${category.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
tickFormatter={formatCurrencyCompact}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
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 (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{payload[0].payload.month}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{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 (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{config?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
/>
|
||||
{filteredCategories.map((category) => (
|
||||
<Area
|
||||
key={category.id}
|
||||
type="monotone"
|
||||
dataKey={category.name}
|
||||
stroke={category.color}
|
||||
strokeWidth={1}
|
||||
fill={`url(#gradient-${category.id})`}
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: category.color,
|
||||
stroke: "hsl(var(--background))",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
|
||||
<MagnetLines
|
||||
rows={8}
|
||||
columns={16}
|
||||
containerSize="100%"
|
||||
lineColor="currentColor"
|
||||
lineWidth="0.4vmin"
|
||||
lineHeight="5vmin"
|
||||
baseAngle={0}
|
||||
className="text-welcome-banner-foreground"
|
||||
disabled={disableMagnetlines}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative tracking-tight text-welcome-banner-foreground">
|
||||
<h1 className="text-xl">
|
||||
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<WidgetEmptyState
|
||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa encontrada"
|
||||
description="Quando houver despesas registradas, elas aparecerão aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<RiListUnordered className="size-3.5 mr-1" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<RiPieChart2Line className="size-3.5 mr-1" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{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 (
|
||||
<div
|
||||
key={category.categoryId}
|
||||
className="flex flex-col py-2 border-b border-dashed last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
colorIndex={index}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{category.categoryName}
|
||||
</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(category.percentageOfTotal)} da
|
||||
despesa total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
hasIncrease
|
||||
? "text-destructive"
|
||||
: hasDecrease
|
||||
? "text-success"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{hasIncrease && <RiArrowUpSFill className="size-3" />}
|
||||
{hasDecrease && <RiArrowDownSFill className="size-3" />}
|
||||
{formatPercentage(category.percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBudget && category.budgetUsedPercentage !== null && (
|
||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||
<RiWallet3Line
|
||||
className={`size-3 ${
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}
|
||||
>
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
{formatPercentage(category.budgetUsedPercentage)} do
|
||||
limite - excedeu em {formatCurrency(exceededAmount)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(category.budgetUsedPercentage)} do
|
||||
limite
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={(entry) => formatPercentage(entry.percentage)}
|
||||
outerRadius={75}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
{data.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
{formatCurrency(data.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercentage(data.percentage)} do total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
||||
{chartData.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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<Budget | null>(null);
|
||||
|
||||
const categories = useMemo<BudgetCategory[]>(
|
||||
() =>
|
||||
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 (
|
||||
<WidgetEmptyState
|
||||
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum orçamento para o período"
|
||||
description="Cadastre orçamentos para acompanhar o progresso das metas."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col">
|
||||
{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 (
|
||||
<li
|
||||
key={item.id}
|
||||
className="border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={item.categoryIcon}
|
||||
name={item.categoryName}
|
||||
colorIndex={index}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{item.categoryName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
||||
<MoneyValues amount={item.budgetAmount} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={`text-xs font-medium ${statusColor}`}>
|
||||
{formatPercentage(percentageDelta, true)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleEdit(item)}
|
||||
aria-label={`Editar orçamento de ${item.categoryName}`}
|
||||
>
|
||||
<RiPencilLine className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 ml-11">
|
||||
<Progress value={progressValue} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<BudgetDialog
|
||||
mode="update"
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<WidgetEmptyState
|
||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma receita encontrada"
|
||||
description="Quando houver receitas registradas, elas aparecerão aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<RiListUnordered className="size-3.5 mr-1" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<RiPieChart2Line className="size-3.5 mr-1" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{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 (
|
||||
<div
|
||||
key={category.categoryId}
|
||||
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
colorIndex={index}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{category.categoryName}
|
||||
</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(category.percentageOfTotal)} da
|
||||
receita total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
hasIncrease
|
||||
? "text-success"
|
||||
: hasDecrease
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{hasIncrease && <RiArrowUpSFill className="size-3" />}
|
||||
{hasDecrease && <RiArrowDownSFill className="size-3" />}
|
||||
{formatPercentage(category.percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBudget &&
|
||||
category.budgetUsedPercentage !== null &&
|
||||
category.budgetAmount !== null && (
|
||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||
<RiWallet3Line
|
||||
className={`size-3 ${
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}
|
||||
>
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
{formatPercentage(category.budgetUsedPercentage)} do
|
||||
limite {formatCurrency(category.budgetAmount)} -
|
||||
excedeu em {formatCurrency(exceededAmount)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(category.budgetUsedPercentage)} do
|
||||
limite {formatCurrency(category.budgetAmount)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={(entry) => formatPercentage(entry.percentage)}
|
||||
outerRadius={75}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
{data.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
{formatCurrency(data.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercentage(data.percentage)} do total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
||||
{chartData.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckboxCircleFill,
|
||||
} from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useState } from "react";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
type InstallmentGroupCardProps = {
|
||||
group: InstallmentGroup;
|
||||
selectedInstallments: Set<string>;
|
||||
onToggleGroup: () => void;
|
||||
onToggleInstallment: (installmentId: string) => void;
|
||||
};
|
||||
|
||||
export function InstallmentGroupCard({
|
||||
group,
|
||||
selectedInstallments,
|
||||
onToggleGroup,
|
||||
onToggleInstallment,
|
||||
}: InstallmentGroupCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
|
||||
const unpaidCount = unpaidInstallments.length;
|
||||
|
||||
const isFullySelected =
|
||||
selectedInstallments.size === unpaidInstallments.length &&
|
||||
unpaidInstallments.length > 0;
|
||||
|
||||
const progress =
|
||||
group.totalInstallments > 0
|
||||
? (group.paidInstallments / group.totalInstallments) * 100
|
||||
: 0;
|
||||
|
||||
const selectedAmount = group.pendingInstallments
|
||||
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
|
||||
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||
|
||||
// Calcular valor total de todas as parcelas (pagas + pendentes)
|
||||
const totalAmount = group.pendingInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
// Calcular valor pendente (apenas não pagas)
|
||||
const pendingAmount = unpaidInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn(isFullySelected && "border-primary/50")}>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* Header do card */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isFullySelected}
|
||||
onCheckedChange={onToggleGroup}
|
||||
className="mt-1"
|
||||
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{group.cartaoLogo && (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="h-6 w-auto object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium truncate">{group.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
| {group.cartaoName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">Total:</span>
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
className="text-base font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pendente:
|
||||
</span>
|
||||
<MoneyValues
|
||||
amount={pendingAmount}
|
||||
className="text-sm font-medium text-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{group.paidInstallments} de {group.totalInstallments} pagas
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||
</span>
|
||||
{selectedInstallments.size > 0 && (
|
||||
<span className="text-primary font-medium">
|
||||
• Selecionado:{" "}
|
||||
<MoneyValues
|
||||
amount={selectedAmount}
|
||||
className="text-xs font-medium text-primary inline"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Botão de expandir */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
Ocultar parcelas ({group.pendingInstallments.length})
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
Ver parcelas ({group.pendingInstallments.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de parcelas expandida */}
|
||||
{isExpanded && (
|
||||
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
|
||||
{group.pendingInstallments.map((installment) => {
|
||||
const isSelected = selectedInstallments.has(installment.id);
|
||||
const isPaid = installment.isSettled;
|
||||
const dueDate = installment.dueDate
|
||||
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
|
||||
: format(installment.purchaseDate, "dd/MM/yyyy", {
|
||||
locale: ptBR,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={installment.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border p-2 transition-colors",
|
||||
isSelected && !isPaid && "border-primary/50 bg-primary/5",
|
||||
isPaid &&
|
||||
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPaid ? false : isSelected}
|
||||
disabled={isPaid}
|
||||
onCheckedChange={() =>
|
||||
!isPaid && onToggleInstallment(installment.id)
|
||||
}
|
||||
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isPaid &&
|
||||
"text-success line-through decoration-success/50",
|
||||
)}
|
||||
>
|
||||
Parcela {installment.currentInstallment}/
|
||||
{group.totalInstallments}
|
||||
{isPaid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-xs border-none text-success"
|
||||
>
|
||||
<RiCheckboxCircleFill /> Pago
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-1",
|
||||
isPaid ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Vencimento: {dueDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MoneyValues
|
||||
amount={installment.amount}
|
||||
className={cn(
|
||||
"shrink-0 text-sm",
|
||||
isPaid && "text-success",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<WidgetEmptyState
|
||||
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa parcelada"
|
||||
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{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 (
|
||||
<li
|
||||
key={expense.id}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{expense.name}
|
||||
</p>
|
||||
{compactLabel && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
{compactLabel}
|
||||
{isLast && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Image
|
||||
src="/icones/party.svg"
|
||||
alt="Última parcela"
|
||||
width={14}
|
||||
height={14}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="sr-only">Última parcela</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Última parcela!
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground ">
|
||||
{endDate && `Termina em ${endDate}`}
|
||||
{" | Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-medium"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
|
||||
<Progress value={progress} className="h-2 mt-1" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [modalState, setModalState] = useState<ModalState>("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 (
|
||||
<>
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{items.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBillLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma fatura para o período selecionado"
|
||||
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{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 (
|
||||
<li
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<div className="flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||
{logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
alt={`Logo do cartão ${invoice.cardName}`}
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-semibold uppercase text-muted-foreground">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{(() => {
|
||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
const linkNode = (
|
||||
<Link
|
||||
prefetch
|
||||
href={`/cartoes/${
|
||||
invoice.cardId
|
||||
}/fatura?periodo=${formatPeriodForUrl(
|
||||
invoice.period,
|
||||
)}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{invoice.cardName}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (!hasBreakdown) {
|
||||
return linkNode;
|
||||
}
|
||||
|
||||
const totalForShare = Math.abs(invoice.totalAmount);
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
{linkNode}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-72 space-y-3"
|
||||
>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Distribuição por pagador
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{breakdown.map((share, index) => (
|
||||
<li
|
||||
key={`${invoice.id}-${
|
||||
share.pagadorId ??
|
||||
share.pagadorName ??
|
||||
index
|
||||
}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(share.pagadorAvatar)}
|
||||
alt={`Avatar de ${share.pagadorName}`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{buildInitials(share.pagadorName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{share.pagadorName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getShareLabel(
|
||||
share.amount,
|
||||
totalForShare,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
})()}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
||||
{isPaid && paymentInfo ? (
|
||||
<span className="text-success">
|
||||
{paymentInfo.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isPaid}
|
||||
onClick={() => handleOpenModal(invoice.id)}
|
||||
variant={"link"}
|
||||
className="p-0 h-auto disabled:opacity-100"
|
||||
>
|
||||
{isPaid ? (
|
||||
<span className="text-success flex items-center gap-1">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : isOverdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">
|
||||
Pagar
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Pagar</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCloseModal();
|
||||
return;
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (modalState === "processing") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
handleCloseModal();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (modalState === "processing") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento confirmado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status da fatura. O lançamento do pagamento
|
||||
aparecerá no extrato em instantes.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<ModalFooter className="sm:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os dados antes de confirmar. Vamos registrar a fatura
|
||||
como paga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedInvoice ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10">
|
||||
{selectedLogo ? (
|
||||
<Image
|
||||
src={selectedLogo}
|
||||
alt={`Logo do cartão ${selectedInvoice.cardName}`}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase text-primary">
|
||||
{buildInitials(selectedInvoice.cardName)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Cartão
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{selectedInvoice.cardName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{selectedInvoice.paymentStatus !==
|
||||
INVOICE_PAYMENT_STATUS.PAID ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
parseDueDate(
|
||||
selectedInvoice.period,
|
||||
selectedInvoice.dueDay,
|
||||
).label
|
||||
}
|
||||
</p>
|
||||
) : null}
|
||||
{selectedInvoice.paymentStatus ===
|
||||
INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? (
|
||||
<p className="text-sm text-success">
|
||||
{selectedPaymentInfo.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor da Fatura
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={Math.abs(selectedInvoice.totalAmount)}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(
|
||||
INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus],
|
||||
)}
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModalFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCloseModal}
|
||||
disabled={modalState === "processing"}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmPayment}
|
||||
disabled={modalState === "processing" || isPending}
|
||||
className="relative"
|
||||
>
|
||||
{modalState === "processing" || isPending ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardAccount } from "@/lib/dashboard/accounts";
|
||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||
import MoneyValues from "../money-values";
|
||||
import { WidgetEmptyState } from "../widget-empty-state";
|
||||
|
||||
type MyAccountsWidgetProps = {
|
||||
accounts: DashboardAccount[];
|
||||
totalBalance: number;
|
||||
maxVisible?: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
const resolveLogoSrc = (logo: string | null) => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
||||
return `/logos/${fileName}`;
|
||||
};
|
||||
|
||||
const buildInitials = (name: string) => {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "CC";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
|
||||
export function MyAccountsWidget({
|
||||
accounts,
|
||||
totalBalance,
|
||||
maxVisible = 5,
|
||||
period,
|
||||
}: MyAccountsWidgetProps) {
|
||||
const visibleAccounts = accounts.filter(
|
||||
(account) => !account.excludeFromBalance,
|
||||
);
|
||||
const displayedAccounts = visibleAccounts.slice(0, maxVisible);
|
||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="pb-4 px-0">
|
||||
<CardDescription>Saldo Total</CardDescription>
|
||||
<div className="text-2xl text-foreground">
|
||||
<MoneyValues amount={totalBalance} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="py-2 px-0">
|
||||
{displayedAccounts.length === 0 ? (
|
||||
<div className="-mt-10">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Você ainda não adicionou nenhuma conta"
|
||||
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{displayedAccounts.map((account) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
const initials = buildInitials(account.name);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={account.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{logoSrc ? (
|
||||
<div className="relative size-10 overflow-hidden">
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
fill
|
||||
className="object-contain rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-sm font-semibold uppercase text-secondary-foreground">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/contas/${
|
||||
account.id
|
||||
}/extrato?periodo=${formatPeriodForUrl(period)}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{account.name}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{account.accountType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||
<MoneyValues amount={account.balance} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{visibleAccounts.length > displayedAccounts.length ? (
|
||||
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
||||
+{remainingCount} contas não exibidas
|
||||
</CardFooter>
|
||||
) : 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<Note | null>(null);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [noteDetails, setNoteDetails] = useState<Note | null>(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 (
|
||||
<>
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{mappedNotes.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma anotação ativa"
|
||||
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{mappedNotes.map((note) => (
|
||||
<li
|
||||
key={note.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{buildDisplayTitle(note.title)}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{getTasksSummary(note)}
|
||||
</Badge>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{DATE_FORMATTER.format(new Date(note.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleOpenEdit(note)}
|
||||
aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleOpenDetails(note)}
|
||||
aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
|
||||
note.title,
|
||||
)}`}
|
||||
>
|
||||
<RiFileList2Line className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<NoteDialog
|
||||
mode="update"
|
||||
note={noteToEdit ?? undefined}
|
||||
open={isEditOpen}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<NoteDetailsDialog
|
||||
note={noteDetails}
|
||||
open={isDetailsOpen}
|
||||
onOpenChange={handleDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import type { DashboardPagador } from "@/lib/dashboard/pagadores";
|
||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||
import { WidgetEmptyState } from "../widget-empty-state";
|
||||
|
||||
type PagadoresWidgetProps = {
|
||||
pagadores: DashboardPagador[];
|
||||
};
|
||||
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${Math.abs(value).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
const buildInitials = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "??";
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
const firstPart = parts[0];
|
||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??";
|
||||
}
|
||||
const firstChar = parts[0]?.[0] ?? "";
|
||||
const secondChar = parts[1]?.[0] ?? "";
|
||||
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
||||
};
|
||||
|
||||
export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
|
||||
return (
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{pagadores.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum pagador para o período"
|
||||
description="Quando houver despesas associadas a pagadores, eles aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{pagadores.map((pagador) => {
|
||||
const initials = buildInitials(pagador.name);
|
||||
const hasValidPercentageChange =
|
||||
typeof pagador.percentageChange === "number" &&
|
||||
Number.isFinite(pagador.percentageChange);
|
||||
const percentageChange = hasValidPercentageChange
|
||||
? pagador.percentageChange
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pagador.id}
|
||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(pagador.avatarUrl)}
|
||||
alt={`Avatar de ${pagador.name}`}
|
||||
/>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/pagadores/${pagador.id}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{pagador.name}
|
||||
</span>
|
||||
{pagador.isAdmin && (
|
||||
<RiVerifiedBadgeFill
|
||||
className="size-4 shrink-0 text-blue-500"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{pagador.email ?? "Sem email cadastrado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={pagador.totalExpenses} />
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
percentageChange > 0
|
||||
? "text-destructive"
|
||||
: percentageChange < 0
|
||||
? "text-success"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{percentageChange > 0 && (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
)}
|
||||
{percentageChange < 0 && (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
)}
|
||||
{formatPercentage(percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -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<string, ReactNode> = {
|
||||
"À vista": <RiCheckLine className="size-5" aria-hidden />,
|
||||
Parcelado: <RiLoader2Fill className="size-5" aria-hidden />,
|
||||
Recorrente: <RiRefreshLine className="size-5" aria-hidden />,
|
||||
};
|
||||
|
||||
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 (
|
||||
<WidgetEmptyState
|
||||
icon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa encontrada"
|
||||
description="As distribuições por condição aparecerão conforme novos lançamentos."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.conditions.map((condition) => {
|
||||
const Icon =
|
||||
CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
|
||||
const percentageLabel = formatPercentage(condition.percentage);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={condition.condition}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className={CONDITION_ICON_CLASSES}>{Icon}</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{condition.condition}
|
||||
</p>
|
||||
<MoneyValues amount={condition.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{condition.transactions}{" "}
|
||||
{condition.transactions === 1
|
||||
? "lançamento"
|
||||
: "lançamentos"}
|
||||
</span>
|
||||
<span>{percentageLabel}%</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Progress value={condition.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
|
||||
}
|
||||
|
||||
const icon = getPaymentMethodIcon(paymentMethod);
|
||||
if (icon) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return <RiBankCard2Line className="size-5" aria-hidden />;
|
||||
};
|
||||
|
||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
||||
if (data.methods.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Nenhuma despesa encontrada"
|
||||
description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.methods.map((method) => {
|
||||
const icon = resolveIcon(method.paymentMethod);
|
||||
const percentageLabel = formatPercentage(method.percentage);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={method.paymentMethod}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className={ICON_WRAPPER_CLASS}>{icon}</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{method.paymentMethod}
|
||||
</p>
|
||||
<MoneyValues amount={method.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{method.transactions}{" "}
|
||||
{method.transactions === 1 ? "lançamento" : "lançamentos"}
|
||||
</span>
|
||||
<span>{percentageLabel}%</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Progress value={method.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
||||
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
||||
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const [activeTab, setActiveTab] = useState<"conditions" | "methods">(
|
||||
"conditions",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "conditions" | "methods")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="conditions" className="text-xs">
|
||||
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||
Condições
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="methods" className="text-xs">
|
||||
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||
Formas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="conditions" className="mt-2">
|
||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="methods" className="mt-2">
|
||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<MoneyValues
|
||||
amount={total}
|
||||
className="text-sm font-medium tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Barra de progresso */}
|
||||
<Progress value={confirmedPercentage} className="h-2" />
|
||||
|
||||
{/* Status de confirmados e pendentes */}
|
||||
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RiCheckboxCircleLine className="size-3 shrink-0 text-success" />
|
||||
<MoneyValues amount={confirmed} className="tabular-nums" />
|
||||
<span className="text-xs text-muted-foreground">confirmados</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RiHourglass2Line className="size-3 shrink-0 text-warning" />
|
||||
<MoneyValues amount={pending} className="tabular-nums" />
|
||||
<span className="text-xs text-muted-foreground">pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
||||
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<CardContent className="px-0">
|
||||
<WidgetEmptyState
|
||||
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum valor a receber ou pagar no período"
|
||||
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-6 px-0">
|
||||
<CategorySection
|
||||
title="A Receber"
|
||||
total={data.income.total}
|
||||
confirmed={data.income.confirmed}
|
||||
pending={data.income.pending}
|
||||
/>
|
||||
|
||||
{/* Linha divisória pontilhada */}
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<CategorySection
|
||||
title="A Pagar"
|
||||
total={data.expenses.total}
|
||||
confirmed={data.expenses.confirmed}
|
||||
pending={data.expenses.pending}
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<WidgetEmptyState
|
||||
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa recorrente"
|
||||
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.expenses.map((expense) => {
|
||||
return (
|
||||
<li
|
||||
key={expense.id}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<EstabelecimentoLogo name={expense.name} size={37} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-foreground text-sm font-medium">
|
||||
{expense.name}
|
||||
</p>
|
||||
|
||||
<MoneyValues amount={expense.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{expense.paymentMethod}
|
||||
</span>
|
||||
<span>{formatOccurrences(expense.recurrenceCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
{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 (
|
||||
<Card key={label} className="@container/card gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1">
|
||||
<Icon className="size-4 text-primary" />
|
||||
{label}
|
||||
</CardTitle>
|
||||
<MoneyValues className="text-2xl" amount={metric.current} />
|
||||
<CardAction>
|
||||
<div className={`flex items-center text-xs ${trendColor}`}>
|
||||
<TrendIcon size={16} />
|
||||
{getPercentChange(metric.current, metric.previous)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 text-xs">
|
||||
Mês anterior
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<MoneyValues amount={metric.previous} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
type DotIconProps = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
export default function DotIcon({ color }: DotIconProps) {
|
||||
return (
|
||||
<span>
|
||||
<span className={`${color} flex size-2 rounded-full`}></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<keyof typeof BRAND_ASSETS>
|
||||
).find((entry) => normalized.includes(entry));
|
||||
|
||||
return match ? BRAND_ASSETS[match] : null;
|
||||
};
|
||||
|
||||
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
|
||||
[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<Date>(
|
||||
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 (
|
||||
<Card className="border">
|
||||
<CardHeader className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{logoPath ? (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : cardBrand ? (
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background text-sm font-semibold text-muted-foreground">
|
||||
{cardBrand}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold text-foreground">
|
||||
{cardName}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fatura de {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
|
||||
{/* Destaque Principal */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<DetailItem
|
||||
label="Valor total"
|
||||
value={
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
className="text-2xl text-foreground"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Status da fatura"
|
||||
value={
|
||||
<Badge
|
||||
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
|
||||
className="text-xs"
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[invoiceStatus]}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Informações Gerais */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<DetailItem
|
||||
label="Fechamento"
|
||||
value={
|
||||
<span className="font-medium">Dia {formatDay(closingDay)}</span>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Vencimento"
|
||||
value={<span className="font-medium">Dia {formatDay(dueDay)}</span>}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Bandeira"
|
||||
value={
|
||||
brandAsset ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${cardBrand}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-5 w-auto rounded"
|
||||
/>
|
||||
<span className="truncate">{cardBrand}</span>
|
||||
</div>
|
||||
) : cardBrand ? (
|
||||
<span className="truncate">{cardBrand}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Status cartão"
|
||||
value={
|
||||
cardStatus ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DotIcon color={getCardStatusDotColor(cardStatus)} />
|
||||
<span className="truncate">{cardStatus}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DetailItem
|
||||
label="Limite do cartão"
|
||||
value={limitLabel}
|
||||
className="sm:w-1/2"
|
||||
/>
|
||||
|
||||
{/* Ações */}
|
||||
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{INVOICE_STATUS_DESCRIPTION[invoiceStatus]}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={actionVariantByStatus[invoiceStatus]}
|
||||
disabled={isPending}
|
||||
onClick={handleAction}
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
|
||||
</Button>
|
||||
{invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && (
|
||||
<EditPaymentDateDialog
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label="Editar data de pagamento"
|
||||
>
|
||||
<RiEditLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
currentDate={paymentDate}
|
||||
onDateChange={handleDateChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailItemProps = {
|
||||
label?: string;
|
||||
value: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function DetailItem({ label, value, className }: DetailItemProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
{label && (
|
||||
<span className="block text-xs font-medium uppercase text-muted-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-base text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FontContextValue | null>(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 (
|
||||
<>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root { --font-app: ${getFontVariable(initialSystemFont)}; --font-money: ${getFontVariable(initialMoneyFont)}; }`,
|
||||
}}
|
||||
/>
|
||||
<FontContext value={value}>{children}</FontContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFont() {
|
||||
const ctx = useContext(FontContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useFont must be used within FontProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user