From ea0b8618e0d7909e3ad8bcc68fe58bf9d695a303 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 15 Nov 2025 15:49:36 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adi=C3=A7=C3=A3o=20de=20novos=20=C3=ADc?= =?UTF-8?q?ones=20SVG=20e=20configura=C3=A7=C3=A3o=20do=20ambiente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter - Implementados ícones para modos claro e escuro do ChatGPT - Criado script de inicialização para PostgreSQL com extensão pgcrypto - Adicionado script de configuração de ambiente que faz backup do .env - Configurado tsconfig.json para TypeScript com opções de compilação --- .dockerignore | 61 + .env.example | 48 + .eslintrc.json | 6 + .gitignore | 133 + .vscode/settings.json | 17 + Dockerfile | 96 + README.md | 1030 +++ app/(auth)/login/page.tsx | 11 + app/(auth)/signup/page.tsx | 11 + app/(dashboard)/ajustes/actions.ts | 257 + app/(dashboard)/ajustes/layout.tsx | 23 + app/(dashboard)/ajustes/page.tsx | 85 + app/(dashboard)/anotacoes/actions.ts | 144 + app/(dashboard)/anotacoes/data.ts | 48 + app/(dashboard)/anotacoes/layout.tsx | 23 + app/(dashboard)/anotacoes/loading.tsx | 51 + app/(dashboard)/anotacoes/page.tsx | 14 + app/(dashboard)/calendario/data.ts | 212 + app/(dashboard)/calendario/layout.tsx | 23 + app/(dashboard)/calendario/loading.tsx | 59 + app/(dashboard)/calendario/page.tsx | 47 + .../cartoes/[cartaoId]/fatura/actions.ts | 299 + .../cartoes/[cartaoId]/fatura/data.ts | 104 + .../cartoes/[cartaoId]/fatura/loading.tsx | 41 + .../cartoes/[cartaoId]/fatura/page.tsx | 199 + app/(dashboard)/cartoes/actions.ts | 165 + app/(dashboard)/cartoes/data.ts | 110 + app/(dashboard)/cartoes/layout.tsx | 25 + app/(dashboard)/cartoes/loading.tsx | 33 + app/(dashboard)/cartoes/page.tsx | 14 + .../categorias/[categoryId]/page.tsx | 115 + app/(dashboard)/categorias/actions.ts | 176 + app/(dashboard)/categorias/data.ts | 26 + app/(dashboard)/categorias/layout.tsx | 23 + app/(dashboard)/categorias/loading.tsx | 61 + app/(dashboard)/categorias/page.tsx | 14 + .../contas/[contaId]/extrato/data.ts | 131 + .../contas/[contaId]/extrato/loading.tsx | 38 + .../contas/[contaId]/extrato/page.tsx | 173 + app/(dashboard)/contas/actions.ts | 383 + app/(dashboard)/contas/data.ts | 95 + app/(dashboard)/contas/layout.tsx | 25 + app/(dashboard)/contas/loading.tsx | 36 + app/(dashboard)/contas/page.tsx | 22 + app/(dashboard)/dashboard/loading.tsx | 17 + app/(dashboard)/dashboard/page.tsx | 40 + app/(dashboard)/insights/actions.ts | 817 ++ app/(dashboard)/insights/data.ts | 145 + app/(dashboard)/insights/layout.tsx | 23 + app/(dashboard)/insights/loading.tsx | 42 + app/(dashboard)/insights/page.tsx | 31 + app/(dashboard)/lancamentos/actions.ts | 1403 +++ .../lancamentos/anticipation-actions.ts | 471 + app/(dashboard)/lancamentos/data.ts | 18 + app/(dashboard)/lancamentos/layout.tsx | 25 + app/(dashboard)/lancamentos/loading.tsx | 32 + app/(dashboard)/lancamentos/page.tsx | 84 + app/(dashboard)/layout.tsx | 67 + app/(dashboard)/orcamentos/actions.ts | 190 + app/(dashboard)/orcamentos/data.ts | 125 + app/(dashboard)/orcamentos/layout.tsx | 23 + app/(dashboard)/orcamentos/loading.tsx | 68 + app/(dashboard)/orcamentos/page.tsx | 55 + .../pagadores/[pagadorId]/actions.ts | 612 ++ app/(dashboard)/pagadores/[pagadorId]/data.ts | 53 + .../pagadores/[pagadorId]/loading.tsx | 84 + .../pagadores/[pagadorId]/page.tsx | 384 + app/(dashboard)/pagadores/actions.ts | 337 + app/(dashboard)/pagadores/layout.tsx | 23 + app/(dashboard)/pagadores/loading.tsx | 57 + app/(dashboard)/pagadores/page.tsx | 86 + app/(landing-page)/page.tsx | 534 ++ app/api/auth/[...all]/route.ts | 4 + app/api/health/route.ts | 39 + app/error.tsx | 53 + app/favicon.ico | Bin 0 -> 11630 bytes app/globals.css | 209 + app/layout.tsx | 40 + app/not-found.tsx | 35 + components.json | 26 + components/ajustes/delete-account-form.tsx | 143 + components/ajustes/update-email-form.tsx | 71 + components/ajustes/update-name-form.tsx | 71 + components/ajustes/update-password-form.tsx | 98 + components/animated-theme-toggler.tsx | 122 + components/anotacoes/note-card.tsx | 139 + components/anotacoes/note-details-dialog.tsx | 118 + components/anotacoes/note-dialog.tsx | 470 + components/anotacoes/notes-page.tsx | 165 + components/anotacoes/types.ts | 23 + components/auth/auth-error-alert.tsx | 17 + components/auth/auth-footer.tsx | 17 + components/auth/auth-header.tsx | 17 + components/auth/auth-sidebar.tsx | 34 + components/auth/google-auth-button.tsx | 54 + components/auth/login-form.tsx | 187 + components/auth/logout-button.tsx | 56 + components/auth/signup-form.tsx | 199 + components/calculadora/calculator-dialog.tsx | 109 + components/calculadora/calculator-display.tsx | 49 + components/calculadora/calculator-keypad.tsx | 29 + components/calculadora/calculator.tsx | 37 + components/calendario/calendar-grid.tsx | 42 + components/calendario/calendar-legend.tsx | 36 + components/calendario/day-cell.tsx | 165 + components/calendario/event-modal.tsx | 229 + components/calendario/monthly-calendar.tsx | 126 + components/calendario/types.ts | 61 + components/calendario/utils.ts | 61 + components/cartoes/card-dialog.tsx | 250 + components/cartoes/card-form-fields.tsx | 214 + components/cartoes/card-item.tsx | 307 + components/cartoes/card-select-items.tsx | 89 + components/cartoes/cards-page.tsx | 173 + components/cartoes/constants.ts | 7 + components/cartoes/types.ts | 27 + components/categorias/categories-page.tsx | 167 + components/categorias/category-card.tsx | 94 + .../categorias/category-detail-header.tsx | 149 + components/categorias/category-dialog.tsx | 189 + .../categorias/category-form-fields.tsx | 128 + components/categorias/category-icon.tsx | 28 + .../categorias/category-select-items.tsx | 20 + components/categorias/types.ts | 18 + components/confirm-action-dialog.tsx | 116 + components/contas/account-card.tsx | 128 + components/contas/account-dialog.tsx | 268 + components/contas/account-form-fields.tsx | 123 + components/contas/account-select-items.tsx | 20 + components/contas/account-statement-card.tsx | 219 + components/contas/accounts-page.tsx | 206 + components/contas/transfer-dialog.tsx | 223 + components/contas/types.ts | 21 + components/dashboard/boletos-widget.tsx | 364 + components/dashboard/dashboard-grid.tsx | 25 + components/dashboard/dashboard-welcome.tsx | 76 + .../dashboard/expenses-by-category-widget.tsx | 179 + .../dashboard/income-by-category-widget.tsx | 182 + .../income-expense-balance-widget.tsx | 175 + .../dashboard/installment-expenses-widget.tsx | 190 + components/dashboard/invoices-widget.tsx | 561 ++ components/dashboard/my-accounts-widget.tsx | 130 + .../dashboard/payment-conditions-widget.tsx | 88 + .../dashboard/payment-methods-widget.tsx | 87 + .../dashboard/payment-status-widget.tsx | 100 + .../purchases-by-category-widget.tsx | 228 + .../dashboard/recent-transactions-widget.tsx | 109 + .../dashboard/recurring-expenses-widget.tsx | 67 + components/dashboard/section-cards.tsx | 97 + .../dashboard/top-establishments-widget.tsx | 101 + components/dashboard/top-expenses-widget.tsx | 177 + components/dot-icon.tsx | 11 + components/empty-state.tsx | 53 + .../faturas/edit-payment-date-dialog.tsx | 75 + components/faturas/invoice-summary-card.tsx | 361 + components/header-dashboard.tsx | 32 + components/insights/insights-grid.tsx | 110 + components/insights/insights-page.tsx | 255 + components/insights/model-selector.tsx | 236 + .../anticipate-installments-dialog.tsx | 489 ++ .../anticipation-history-dialog.tsx | 135 + .../installment-selection-table.tsx | 155 + .../dialogs/bulk-action-dialog.tsx | 162 + .../dialogs/lancamento-details-dialog.tsx | 210 + .../basic-fields-section.tsx | 95 + .../boleto-fields-section.tsx | 42 + .../lancamento-dialog/category-section.tsx | 105 + .../lancamento-dialog/condition-section.tsx | 95 + .../lancamento-dialog-types.ts | 87 + .../lancamento-dialog/lancamento-dialog.tsx | 421 + .../lancamento-dialog/note-section.tsx | 20 + .../lancamento-dialog/pagador-section.tsx | 101 + .../payment-method-section.tsx | 288 + .../split-settlement-section.tsx | 58 + .../lancamentos/dialogs/mass-add-dialog.tsx | 608 ++ components/lancamentos/index.ts | 23 + .../lancamentos/page/lancamentos-page.tsx | 503 ++ components/lancamentos/select-items.tsx | 120 + .../lancamentos/shared/anticipation-card.tsx | 205 + .../shared/estabelecimento-input.tsx | 129 + .../shared/installment-timeline.tsx | 92 + .../lancamentos/table/lancamentos-filters.tsx | 463 + .../lancamentos/table/lancamentos-table.tsx | 857 ++ components/lancamentos/types.ts | 58 + components/logo-picker.tsx | 160 + components/logo.tsx | 43 + components/magnet-lines.tsx | 101 + components/money-values.tsx | 41 + components/month-picker/loading-spinner.tsx | 9 + components/month-picker/month-picker.tsx | 121 + components/month-picker/nav-button.tsx | 33 + components/month-picker/return-button.tsx | 27 + .../notifications/notification-bell.tsx | 205 + components/orcamentos/budget-card.tsx | 110 + components/orcamentos/budget-dialog.tsx | 344 + components/orcamentos/budgets-page.tsx | 147 + components/orcamentos/types.ts | 21 + .../details/pagador-card-usage-card.tsx | 90 + .../details/pagador-history-card.tsx | 107 + .../pagadores/details/pagador-info-card.tsx | 407 + .../details/pagador-monthly-summary-card.tsx | 119 + .../details/pagador-payment-method-cards.tsx | 106 + .../details/pagador-sharing-card.tsx | 160 + components/pagadores/pagador-card.tsx | 121 + components/pagadores/pagador-dialog.tsx | 348 + components/pagadores/pagador-select-items.tsx | 20 + components/pagadores/pagadores-page.tsx | 203 + components/pagadores/types.ts | 27 + components/page-description.tsx | 21 + components/privacy-mode-toggle.tsx | 78 + components/privacy-provider.tsx | 58 + components/sidebar/app-sidebar.tsx | 79 + components/sidebar/nav-link.tsx | 150 + components/sidebar/nav-main.tsx | 203 + components/sidebar/nav-secondary.tsx | 75 + components/sidebar/nav-user.tsx | 58 + .../account-statement-card-skeleton.tsx | 43 + .../skeletons/dashboard-grid-skeleton.tsx | 22 + components/skeletons/filter-skeleton.tsx | 20 + components/skeletons/index.ts | 11 + .../invoice-summary-card-skeleton.tsx | 64 + .../skeletons/section-cards-skeleton.tsx | 37 + .../skeletons/transactions-table-skeleton.tsx | 84 + components/skeletons/widget-skeleton.tsx | 44 + components/theme-provider.tsx | 11 + components/type-badge.tsx | 58 + components/ui/alert-dialog.tsx | 157 + components/ui/alert.tsx | 66 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 50 + components/ui/breadcrumb.tsx | 109 + components/ui/button.tsx | 60 + components/ui/calendar.tsx | 216 + components/ui/card.tsx | 92 + components/ui/chart.tsx | 361 + components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 + components/ui/currency-input.tsx | 105 + components/ui/date-picker.tsx | 193 + components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 261 + components/ui/empty.tsx | 104 + components/ui/field.tsx | 248 + components/ui/hover-card.tsx | 44 + components/ui/input.tsx | 21 + components/ui/label.tsx | 24 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/select.tsx | 187 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 725 ++ components/ui/skeleton.tsx | 13 + components/ui/sonner.tsx | 40 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/widget-card.tsx | 128 + components/widget-empty-state.tsx | 30 + db/schema.ts | 618 ++ docker-compose.yml | 162 + drizzle.config.ts | 10 + drizzle/0000_flashy_manta.sql | 224 + drizzle/meta/0000_snapshot.json | 1581 ++++ drizzle/meta/_journal.json | 13 + hooks/use-calculator-keyboard.ts | 94 + hooks/use-calculator-state.ts | 384 + hooks/use-controlled-state.ts | 51 + hooks/use-form-state.ts | 55 + hooks/use-logo-selection.ts | 58 + hooks/use-mobile.ts | 19 + hooks/use-month-period.ts | 85 + lib/accounts/constants.ts | 21 + lib/actions/helpers.ts | 110 + lib/actions/types.ts | 27 + lib/auth/client.ts | 15 + lib/auth/config.ts | 119 + lib/auth/server.ts | 69 + lib/categorias/constants.ts | 14 + lib/categorias/defaults.ts | 90 + lib/categorias/icons.ts | 172 + lib/dashboard/accounts.ts | 113 + lib/dashboard/boletos.ts | 106 + lib/dashboard/categories/category-details.ts | 131 + .../categories/expenses-by-category.ts | 163 + .../categories/income-by-category.ts | 147 + lib/dashboard/common.ts | 13 + .../expenses/installment-expenses.ts | 96 + lib/dashboard/expenses/recurring-expenses.ts | 66 + lib/dashboard/expenses/top-expenses.ts | 84 + lib/dashboard/fetch-dashboard-data.ts | 82 + lib/dashboard/income-expense-balance.ts | 138 + lib/dashboard/invoices.ts | 279 + lib/dashboard/metrics.ts | 155 + lib/dashboard/notifications.ts | 373 + lib/dashboard/payments/payment-conditions.ts | 79 + lib/dashboard/payments/payment-methods.ts | 79 + lib/dashboard/payments/payment-status.ts | 120 + lib/dashboard/purchases-by-category.ts | 145 + lib/dashboard/recent-transactions.ts | 71 + lib/dashboard/top-establishments.ts | 86 + lib/dashboard/widgets/widgets-config.tsx | 192 + lib/db.ts | 39 + lib/faturas.ts | 32 + lib/installments/anticipation-helpers.ts | 143 + lib/installments/anticipation-types.ts | 66 + lib/installments/utils.ts | 79 + lib/lancamentos/categoria-helpers.ts | 83 + lib/lancamentos/constants.ts | 15 + lib/lancamentos/form-helpers.ts | 192 + lib/lancamentos/formatting-helpers.ts | 94 + lib/lancamentos/page-helpers.ts | 521 ++ lib/logo/index.ts | 40 + lib/logo/options.ts | 30 + lib/pagadores/access.ts | 114 + lib/pagadores/constants.ts | 13 + lib/pagadores/defaults.ts | 60 + lib/pagadores/details.ts | 325 + lib/pagadores/notifications.ts | 237 + lib/pagadores/utils.ts | 65 + lib/schemas/common.ts | 134 + lib/schemas/insights.ts | 69 + lib/transferencias/constants.ts | 5 + lib/utils/calculator.ts | 168 + lib/utils/currency.ts | 60 + lib/utils/date.ts | 235 + lib/utils/icons.tsx | 61 + lib/utils/math.ts | 51 + lib/utils/number.ts | 74 + lib/utils/period/index.ts | 381 + lib/utils/string.ts | 43 + lib/utils/ui.ts | 17 + next.config.ts | 21 + package.json | 89 + pnpm-lock.yaml | 7628 +++++++++++++++++ postcss.config.mjs | 5 + proxy.ts | 60 + public/avatares/avatar_001.svg | 1 + public/avatares/avatar_002.svg | 1 + public/avatares/avatar_003.svg | 1 + public/avatares/avatar_004.svg | 1 + public/avatares/avatar_005.svg | 1 + public/avatares/avatar_006.svg | 1 + public/avatares/avatar_007.svg | 1 + public/avatares/avatar_008.svg | 1 + public/avatares/avatar_009.svg | 1 + public/avatares/avatar_010.svg | 21 + public/avatares/avatar_011.svg | 1 + public/avatares/avatar_012.svg | 1 + public/avatares/avatar_013.svg | 1 + public/avatares/avatar_014.svg | 1 + public/avatares/avatar_015.svg | 1 + public/bandeiras/amex.svg | 1 + public/bandeiras/elo.svg | 1 + public/bandeiras/hipercard.svg | 1 + public/bandeiras/mastercard.svg | 14 + public/bandeiras/visa.svg | 11 + public/fonts/aeonik-regular.otf | Bin 0 -> 62096 bytes public/fonts/anthropic-sans.woff2 | Bin 0 -> 127880 bytes public/fonts/font_index.ts | 38 + public/icones/party.svg | 2 + public/logo_small.png | Bin 0 -> 5869 bytes public/logo_text.png | Bin 0 -> 15782 bytes public/logos/99Pay.png | Bin 0 -> 2136 bytes public/logos/alelo.png | Bin 0 -> 1614 bytes public/logos/azul.png | Bin 0 -> 2121 bytes public/logos/b3.png | Bin 0 -> 1454 bytes public/logos/banrisul.png | Bin 0 -> 2945 bytes public/logos/bb.png | Bin 0 -> 1411 bytes public/logos/bmg.png | Bin 0 -> 685 bytes public/logos/bradesco.png | Bin 0 -> 1040 bytes public/logos/brb.png | Bin 0 -> 1332 bytes public/logos/bs2.png | Bin 0 -> 1366 bytes public/logos/btgpactual.png | Bin 0 -> 8936 bytes public/logos/bv.png | Bin 0 -> 1019 bytes public/logos/c6bank.png | Bin 0 -> 1560 bytes public/logos/caixa.png | Bin 0 -> 1002 bytes public/logos/citibank.png | Bin 0 -> 1106 bytes public/logos/cooperativa-cresol.png | Bin 0 -> 1883 bytes public/logos/credicard-on.png | Bin 0 -> 1390 bytes public/logos/credicard-zero.png | Bin 0 -> 1838 bytes public/logos/credicard.png | Bin 0 -> 3443 bytes public/logos/digio.png | Bin 0 -> 1216 bytes public/logos/diners.png | Bin 0 -> 1236 bytes public/logos/elo.png | Bin 0 -> 1557 bytes public/logos/infinitepay.png | Bin 0 -> 2163 bytes public/logos/intermedium.png | Bin 0 -> 2320 bytes public/logos/interpj.png | Bin 0 -> 3209 bytes public/logos/itau-ion.png | Bin 0 -> 1716 bytes public/logos/itau-uniclass.png | Bin 0 -> 1184 bytes public/logos/itau.png | Bin 0 -> 1934 bytes public/logos/itaupersonnalite.png | Bin 0 -> 1194 bytes public/logos/iti.png | Bin 0 -> 2709 bytes public/logos/magalu-pay.png | Bin 0 -> 1443 bytes public/logos/mastercard.png | Bin 0 -> 1065 bytes public/logos/mercadobitcoin.png | Bin 0 -> 6062 bytes public/logos/mercadopago.png | Bin 0 -> 2802 bytes public/logos/mercadopagocartao.png | Bin 0 -> 9025 bytes public/logos/neon.png | Bin 0 -> 2649 bytes public/logos/next.png | Bin 0 -> 898 bytes public/logos/nomad.png | Bin 0 -> 691 bytes public/logos/nu-invest.png | Bin 0 -> 1792 bytes public/logos/nubank.png | Bin 0 -> 766 bytes public/logos/nuconta.png | Bin 0 -> 766 bytes public/logos/original.png | Bin 0 -> 1010 bytes public/logos/pagbank.png | Bin 0 -> 3109 bytes public/logos/pagseguro.png | Bin 0 -> 2835 bytes public/logos/paypal.png | Bin 0 -> 2680 bytes public/logos/petrobras.png | Bin 0 -> 1278 bytes public/logos/picpay.png | Bin 0 -> 907 bytes public/logos/pix.png | Bin 0 -> 1609 bytes public/logos/recargapay.png | Bin 0 -> 2602 bytes public/logos/renner.png | Bin 0 -> 1638 bytes public/logos/safra.png | Bin 0 -> 1808 bytes public/logos/samsung.png | Bin 0 -> 1579 bytes public/logos/santander-private.png | Bin 0 -> 1650 bytes public/logos/santander.png | Bin 0 -> 1044 bytes public/logos/sofisadireto.png | Bin 0 -> 1356 bytes public/logos/stone.png | Bin 0 -> 1104 bytes public/logos/uber.drive.png | Bin 0 -> 986 bytes public/logos/visa.png | Bin 0 -> 1016 bytes public/logos/vr.png | Bin 0 -> 2053 bytes public/logos/vuon.png | Bin 0 -> 3045 bytes public/logos/xp.png | Bin 0 -> 1392 bytes public/providers/chatgpt.svg | 3 + public/providers/chatgpt_dark_mode.svg | 3 + public/providers/claude.svg | 3 + public/providers/gemini.svg | 10 + public/providers/openrouter_dark.svg | 1 + public/providers/openrouter_light.svg | 1 + scripts/postgres/init.sql | 11 + scripts/setup-env.sh | 38 + tsconfig.json | 41 + 441 files changed, 53569 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/signup/page.tsx create mode 100644 app/(dashboard)/ajustes/actions.ts create mode 100644 app/(dashboard)/ajustes/layout.tsx create mode 100644 app/(dashboard)/ajustes/page.tsx create mode 100644 app/(dashboard)/anotacoes/actions.ts create mode 100644 app/(dashboard)/anotacoes/data.ts create mode 100644 app/(dashboard)/anotacoes/layout.tsx create mode 100644 app/(dashboard)/anotacoes/loading.tsx create mode 100644 app/(dashboard)/anotacoes/page.tsx create mode 100644 app/(dashboard)/calendario/data.ts create mode 100644 app/(dashboard)/calendario/layout.tsx create mode 100644 app/(dashboard)/calendario/loading.tsx create mode 100644 app/(dashboard)/calendario/page.tsx create mode 100644 app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts create mode 100644 app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts create mode 100644 app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx create mode 100644 app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx create mode 100644 app/(dashboard)/cartoes/actions.ts create mode 100644 app/(dashboard)/cartoes/data.ts create mode 100644 app/(dashboard)/cartoes/layout.tsx create mode 100644 app/(dashboard)/cartoes/loading.tsx create mode 100644 app/(dashboard)/cartoes/page.tsx create mode 100644 app/(dashboard)/categorias/[categoryId]/page.tsx create mode 100644 app/(dashboard)/categorias/actions.ts create mode 100644 app/(dashboard)/categorias/data.ts create mode 100644 app/(dashboard)/categorias/layout.tsx create mode 100644 app/(dashboard)/categorias/loading.tsx create mode 100644 app/(dashboard)/categorias/page.tsx create mode 100644 app/(dashboard)/contas/[contaId]/extrato/data.ts create mode 100644 app/(dashboard)/contas/[contaId]/extrato/loading.tsx create mode 100644 app/(dashboard)/contas/[contaId]/extrato/page.tsx create mode 100644 app/(dashboard)/contas/actions.ts create mode 100644 app/(dashboard)/contas/data.ts create mode 100644 app/(dashboard)/contas/layout.tsx create mode 100644 app/(dashboard)/contas/loading.tsx create mode 100644 app/(dashboard)/contas/page.tsx create mode 100644 app/(dashboard)/dashboard/loading.tsx create mode 100644 app/(dashboard)/dashboard/page.tsx create mode 100644 app/(dashboard)/insights/actions.ts create mode 100644 app/(dashboard)/insights/data.ts create mode 100644 app/(dashboard)/insights/layout.tsx create mode 100644 app/(dashboard)/insights/loading.tsx create mode 100644 app/(dashboard)/insights/page.tsx create mode 100644 app/(dashboard)/lancamentos/actions.ts create mode 100644 app/(dashboard)/lancamentos/anticipation-actions.ts create mode 100644 app/(dashboard)/lancamentos/data.ts create mode 100644 app/(dashboard)/lancamentos/layout.tsx create mode 100644 app/(dashboard)/lancamentos/loading.tsx create mode 100644 app/(dashboard)/lancamentos/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/orcamentos/actions.ts create mode 100644 app/(dashboard)/orcamentos/data.ts create mode 100644 app/(dashboard)/orcamentos/layout.tsx create mode 100644 app/(dashboard)/orcamentos/loading.tsx create mode 100644 app/(dashboard)/orcamentos/page.tsx create mode 100644 app/(dashboard)/pagadores/[pagadorId]/actions.ts create mode 100644 app/(dashboard)/pagadores/[pagadorId]/data.ts create mode 100644 app/(dashboard)/pagadores/[pagadorId]/loading.tsx create mode 100644 app/(dashboard)/pagadores/[pagadorId]/page.tsx create mode 100644 app/(dashboard)/pagadores/actions.ts create mode 100644 app/(dashboard)/pagadores/layout.tsx create mode 100644 app/(dashboard)/pagadores/loading.tsx create mode 100644 app/(dashboard)/pagadores/page.tsx create mode 100644 app/(landing-page)/page.tsx create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/error.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 components.json create mode 100644 components/ajustes/delete-account-form.tsx create mode 100644 components/ajustes/update-email-form.tsx create mode 100644 components/ajustes/update-name-form.tsx create mode 100644 components/ajustes/update-password-form.tsx create mode 100644 components/animated-theme-toggler.tsx create mode 100644 components/anotacoes/note-card.tsx create mode 100644 components/anotacoes/note-details-dialog.tsx create mode 100644 components/anotacoes/note-dialog.tsx create mode 100644 components/anotacoes/notes-page.tsx create mode 100644 components/anotacoes/types.ts create mode 100644 components/auth/auth-error-alert.tsx create mode 100644 components/auth/auth-footer.tsx create mode 100644 components/auth/auth-header.tsx create mode 100644 components/auth/auth-sidebar.tsx create mode 100644 components/auth/google-auth-button.tsx create mode 100644 components/auth/login-form.tsx create mode 100644 components/auth/logout-button.tsx create mode 100644 components/auth/signup-form.tsx create mode 100644 components/calculadora/calculator-dialog.tsx create mode 100644 components/calculadora/calculator-display.tsx create mode 100644 components/calculadora/calculator-keypad.tsx create mode 100644 components/calculadora/calculator.tsx create mode 100644 components/calendario/calendar-grid.tsx create mode 100644 components/calendario/calendar-legend.tsx create mode 100644 components/calendario/day-cell.tsx create mode 100644 components/calendario/event-modal.tsx create mode 100644 components/calendario/monthly-calendar.tsx create mode 100644 components/calendario/types.ts create mode 100644 components/calendario/utils.ts create mode 100644 components/cartoes/card-dialog.tsx create mode 100644 components/cartoes/card-form-fields.tsx create mode 100644 components/cartoes/card-item.tsx create mode 100644 components/cartoes/card-select-items.tsx create mode 100644 components/cartoes/cards-page.tsx create mode 100644 components/cartoes/constants.ts create mode 100644 components/cartoes/types.ts create mode 100644 components/categorias/categories-page.tsx create mode 100644 components/categorias/category-card.tsx create mode 100644 components/categorias/category-detail-header.tsx create mode 100644 components/categorias/category-dialog.tsx create mode 100644 components/categorias/category-form-fields.tsx create mode 100644 components/categorias/category-icon.tsx create mode 100644 components/categorias/category-select-items.tsx create mode 100644 components/categorias/types.ts create mode 100644 components/confirm-action-dialog.tsx create mode 100644 components/contas/account-card.tsx create mode 100644 components/contas/account-dialog.tsx create mode 100644 components/contas/account-form-fields.tsx create mode 100644 components/contas/account-select-items.tsx create mode 100644 components/contas/account-statement-card.tsx create mode 100644 components/contas/accounts-page.tsx create mode 100644 components/contas/transfer-dialog.tsx create mode 100644 components/contas/types.ts create mode 100644 components/dashboard/boletos-widget.tsx create mode 100644 components/dashboard/dashboard-grid.tsx create mode 100644 components/dashboard/dashboard-welcome.tsx create mode 100644 components/dashboard/expenses-by-category-widget.tsx create mode 100644 components/dashboard/income-by-category-widget.tsx create mode 100644 components/dashboard/income-expense-balance-widget.tsx create mode 100644 components/dashboard/installment-expenses-widget.tsx create mode 100644 components/dashboard/invoices-widget.tsx create mode 100644 components/dashboard/my-accounts-widget.tsx create mode 100644 components/dashboard/payment-conditions-widget.tsx create mode 100644 components/dashboard/payment-methods-widget.tsx create mode 100644 components/dashboard/payment-status-widget.tsx create mode 100644 components/dashboard/purchases-by-category-widget.tsx create mode 100644 components/dashboard/recent-transactions-widget.tsx create mode 100644 components/dashboard/recurring-expenses-widget.tsx create mode 100644 components/dashboard/section-cards.tsx create mode 100644 components/dashboard/top-establishments-widget.tsx create mode 100644 components/dashboard/top-expenses-widget.tsx create mode 100644 components/dot-icon.tsx create mode 100644 components/empty-state.tsx create mode 100644 components/faturas/edit-payment-date-dialog.tsx create mode 100644 components/faturas/invoice-summary-card.tsx create mode 100644 components/header-dashboard.tsx create mode 100644 components/insights/insights-grid.tsx create mode 100644 components/insights/insights-page.tsx create mode 100644 components/insights/model-selector.tsx create mode 100644 components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx create mode 100644 components/lancamentos/dialogs/anticipate-installments-dialog/anticipation-history-dialog.tsx create mode 100644 components/lancamentos/dialogs/anticipate-installments-dialog/installment-selection-table.tsx create mode 100644 components/lancamentos/dialogs/bulk-action-dialog.tsx create mode 100644 components/lancamentos/dialogs/lancamento-details-dialog.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/basic-fields-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/boleto-fields-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/category-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/condition-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts create mode 100644 components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/note-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/pagador-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx create mode 100644 components/lancamentos/dialogs/lancamento-dialog/split-settlement-section.tsx create mode 100644 components/lancamentos/dialogs/mass-add-dialog.tsx create mode 100644 components/lancamentos/index.ts create mode 100644 components/lancamentos/page/lancamentos-page.tsx create mode 100644 components/lancamentos/select-items.tsx create mode 100644 components/lancamentos/shared/anticipation-card.tsx create mode 100644 components/lancamentos/shared/estabelecimento-input.tsx create mode 100644 components/lancamentos/shared/installment-timeline.tsx create mode 100644 components/lancamentos/table/lancamentos-filters.tsx create mode 100644 components/lancamentos/table/lancamentos-table.tsx create mode 100644 components/lancamentos/types.ts create mode 100644 components/logo-picker.tsx create mode 100644 components/logo.tsx create mode 100644 components/magnet-lines.tsx create mode 100644 components/money-values.tsx create mode 100644 components/month-picker/loading-spinner.tsx create mode 100644 components/month-picker/month-picker.tsx create mode 100644 components/month-picker/nav-button.tsx create mode 100644 components/month-picker/return-button.tsx create mode 100644 components/notifications/notification-bell.tsx create mode 100644 components/orcamentos/budget-card.tsx create mode 100644 components/orcamentos/budget-dialog.tsx create mode 100644 components/orcamentos/budgets-page.tsx create mode 100644 components/orcamentos/types.ts create mode 100644 components/pagadores/details/pagador-card-usage-card.tsx create mode 100644 components/pagadores/details/pagador-history-card.tsx create mode 100644 components/pagadores/details/pagador-info-card.tsx create mode 100644 components/pagadores/details/pagador-monthly-summary-card.tsx create mode 100644 components/pagadores/details/pagador-payment-method-cards.tsx create mode 100644 components/pagadores/details/pagador-sharing-card.tsx create mode 100644 components/pagadores/pagador-card.tsx create mode 100644 components/pagadores/pagador-dialog.tsx create mode 100644 components/pagadores/pagador-select-items.tsx create mode 100644 components/pagadores/pagadores-page.tsx create mode 100644 components/pagadores/types.ts create mode 100644 components/page-description.tsx create mode 100644 components/privacy-mode-toggle.tsx create mode 100644 components/privacy-provider.tsx create mode 100644 components/sidebar/app-sidebar.tsx create mode 100644 components/sidebar/nav-link.tsx create mode 100644 components/sidebar/nav-main.tsx create mode 100644 components/sidebar/nav-secondary.tsx create mode 100644 components/sidebar/nav-user.tsx create mode 100644 components/skeletons/account-statement-card-skeleton.tsx create mode 100644 components/skeletons/dashboard-grid-skeleton.tsx create mode 100644 components/skeletons/filter-skeleton.tsx create mode 100644 components/skeletons/index.ts create mode 100644 components/skeletons/invoice-summary-card-skeleton.tsx create mode 100644 components/skeletons/section-cards-skeleton.tsx create mode 100644 components/skeletons/transactions-table-skeleton.tsx create mode 100644 components/skeletons/widget-skeleton.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/type-badge.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/currency-input.tsx create mode 100644 components/ui/date-picker.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/widget-card.tsx create mode 100644 components/widget-empty-state.tsx create mode 100644 db/schema.ts create mode 100644 docker-compose.yml create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_flashy_manta.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 hooks/use-calculator-keyboard.ts create mode 100644 hooks/use-calculator-state.ts create mode 100644 hooks/use-controlled-state.ts create mode 100644 hooks/use-form-state.ts create mode 100644 hooks/use-logo-selection.ts create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-month-period.ts create mode 100644 lib/accounts/constants.ts create mode 100644 lib/actions/helpers.ts create mode 100644 lib/actions/types.ts create mode 100644 lib/auth/client.ts create mode 100644 lib/auth/config.ts create mode 100644 lib/auth/server.ts create mode 100644 lib/categorias/constants.ts create mode 100644 lib/categorias/defaults.ts create mode 100644 lib/categorias/icons.ts create mode 100644 lib/dashboard/accounts.ts create mode 100644 lib/dashboard/boletos.ts create mode 100644 lib/dashboard/categories/category-details.ts create mode 100644 lib/dashboard/categories/expenses-by-category.ts create mode 100644 lib/dashboard/categories/income-by-category.ts create mode 100644 lib/dashboard/common.ts create mode 100644 lib/dashboard/expenses/installment-expenses.ts create mode 100644 lib/dashboard/expenses/recurring-expenses.ts create mode 100644 lib/dashboard/expenses/top-expenses.ts create mode 100644 lib/dashboard/fetch-dashboard-data.ts create mode 100644 lib/dashboard/income-expense-balance.ts create mode 100644 lib/dashboard/invoices.ts create mode 100644 lib/dashboard/metrics.ts create mode 100644 lib/dashboard/notifications.ts create mode 100644 lib/dashboard/payments/payment-conditions.ts create mode 100644 lib/dashboard/payments/payment-methods.ts create mode 100644 lib/dashboard/payments/payment-status.ts create mode 100644 lib/dashboard/purchases-by-category.ts create mode 100644 lib/dashboard/recent-transactions.ts create mode 100644 lib/dashboard/top-establishments.ts create mode 100644 lib/dashboard/widgets/widgets-config.tsx create mode 100644 lib/db.ts create mode 100644 lib/faturas.ts create mode 100644 lib/installments/anticipation-helpers.ts create mode 100644 lib/installments/anticipation-types.ts create mode 100644 lib/installments/utils.ts create mode 100644 lib/lancamentos/categoria-helpers.ts create mode 100644 lib/lancamentos/constants.ts create mode 100644 lib/lancamentos/form-helpers.ts create mode 100644 lib/lancamentos/formatting-helpers.ts create mode 100644 lib/lancamentos/page-helpers.ts create mode 100644 lib/logo/index.ts create mode 100644 lib/logo/options.ts create mode 100644 lib/pagadores/access.ts create mode 100644 lib/pagadores/constants.ts create mode 100644 lib/pagadores/defaults.ts create mode 100644 lib/pagadores/details.ts create mode 100644 lib/pagadores/notifications.ts create mode 100644 lib/pagadores/utils.ts create mode 100644 lib/schemas/common.ts create mode 100644 lib/schemas/insights.ts create mode 100644 lib/transferencias/constants.ts create mode 100644 lib/utils/calculator.ts create mode 100644 lib/utils/currency.ts create mode 100644 lib/utils/date.ts create mode 100644 lib/utils/icons.tsx create mode 100644 lib/utils/math.ts create mode 100644 lib/utils/number.ts create mode 100644 lib/utils/period/index.ts create mode 100644 lib/utils/string.ts create mode 100644 lib/utils/ui.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 proxy.ts create mode 100644 public/avatares/avatar_001.svg create mode 100644 public/avatares/avatar_002.svg create mode 100644 public/avatares/avatar_003.svg create mode 100644 public/avatares/avatar_004.svg create mode 100644 public/avatares/avatar_005.svg create mode 100644 public/avatares/avatar_006.svg create mode 100644 public/avatares/avatar_007.svg create mode 100644 public/avatares/avatar_008.svg create mode 100644 public/avatares/avatar_009.svg create mode 100644 public/avatares/avatar_010.svg create mode 100644 public/avatares/avatar_011.svg create mode 100644 public/avatares/avatar_012.svg create mode 100644 public/avatares/avatar_013.svg create mode 100644 public/avatares/avatar_014.svg create mode 100644 public/avatares/avatar_015.svg create mode 100644 public/bandeiras/amex.svg create mode 100644 public/bandeiras/elo.svg create mode 100644 public/bandeiras/hipercard.svg create mode 100644 public/bandeiras/mastercard.svg create mode 100644 public/bandeiras/visa.svg create mode 100644 public/fonts/aeonik-regular.otf create mode 100644 public/fonts/anthropic-sans.woff2 create mode 100644 public/fonts/font_index.ts create mode 100644 public/icones/party.svg create mode 100644 public/logo_small.png create mode 100644 public/logo_text.png create mode 100644 public/logos/99Pay.png create mode 100644 public/logos/alelo.png create mode 100644 public/logos/azul.png create mode 100644 public/logos/b3.png create mode 100644 public/logos/banrisul.png create mode 100644 public/logos/bb.png create mode 100644 public/logos/bmg.png create mode 100644 public/logos/bradesco.png create mode 100644 public/logos/brb.png create mode 100644 public/logos/bs2.png create mode 100644 public/logos/btgpactual.png create mode 100644 public/logos/bv.png create mode 100644 public/logos/c6bank.png create mode 100644 public/logos/caixa.png create mode 100644 public/logos/citibank.png create mode 100644 public/logos/cooperativa-cresol.png create mode 100644 public/logos/credicard-on.png create mode 100644 public/logos/credicard-zero.png create mode 100644 public/logos/credicard.png create mode 100644 public/logos/digio.png create mode 100644 public/logos/diners.png create mode 100644 public/logos/elo.png create mode 100644 public/logos/infinitepay.png create mode 100644 public/logos/intermedium.png create mode 100644 public/logos/interpj.png create mode 100644 public/logos/itau-ion.png create mode 100644 public/logos/itau-uniclass.png create mode 100644 public/logos/itau.png create mode 100644 public/logos/itaupersonnalite.png create mode 100644 public/logos/iti.png create mode 100644 public/logos/magalu-pay.png create mode 100644 public/logos/mastercard.png create mode 100644 public/logos/mercadobitcoin.png create mode 100644 public/logos/mercadopago.png create mode 100644 public/logos/mercadopagocartao.png create mode 100644 public/logos/neon.png create mode 100644 public/logos/next.png create mode 100644 public/logos/nomad.png create mode 100644 public/logos/nu-invest.png create mode 100644 public/logos/nubank.png create mode 100644 public/logos/nuconta.png create mode 100644 public/logos/original.png create mode 100644 public/logos/pagbank.png create mode 100644 public/logos/pagseguro.png create mode 100644 public/logos/paypal.png create mode 100644 public/logos/petrobras.png create mode 100644 public/logos/picpay.png create mode 100644 public/logos/pix.png create mode 100644 public/logos/recargapay.png create mode 100644 public/logos/renner.png create mode 100644 public/logos/safra.png create mode 100644 public/logos/samsung.png create mode 100644 public/logos/santander-private.png create mode 100644 public/logos/santander.png create mode 100644 public/logos/sofisadireto.png create mode 100644 public/logos/stone.png create mode 100644 public/logos/uber.drive.png create mode 100644 public/logos/visa.png create mode 100644 public/logos/vr.png create mode 100644 public/logos/vuon.png create mode 100644 public/logos/xp.png create mode 100644 public/providers/chatgpt.svg create mode 100644 public/providers/chatgpt_dark_mode.svg create mode 100644 public/providers/claude.svg create mode 100644 public/providers/gemini.svg create mode 100644 public/providers/openrouter_dark.svg create mode 100644 public/providers/openrouter_light.svg create mode 100644 scripts/postgres/init.sql create mode 100755 scripts/setup-env.sh create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b0aad3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,61 @@ +# Dependências +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store + +# Build do Next.js +.next +out +dist + +# Arquivos de ambiente (serão passados via docker-compose) +.env +.env*.local +.env.development +.env.production + +# Git +.git +.gitignore +.gitattributes + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# CI/CD +.github +.gitlab-ci.yml + +# Testes +coverage +.nyc_output +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx +__tests__ +__mocks__ + +# IDE e editores +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs +*.log + +# Misc +README.md +LICENSE +.eslintcache +.prettierignore +.editorconfig diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48ed0f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================ +# OPENSHEETS - Variáveis de Ambiente +# ============================================ +# +# Setup: cp .env.example .env +# Docs: README.md +# +# ============================================ + +# === Database === +# PostgreSQL local (Docker): use host "db" +# PostgreSQL local (sem Docker): use host "localhost" +# PostgreSQL remoto: use URL completa do provider +DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db + +# Credenciais do PostgreSQL (apenas para Docker local) - Alterar +POSTGRES_USER=opensheets +POSTGRES_PASSWORD=opensheets_dev_password +POSTGRES_DB=opensheets_db + +# Provider: "local" para Docker, "remote" para Supabase/Neon/etc +DB_PROVIDER=local + +# === Better Auth === +# Gere com: openssl rand -base64 32 +BETTER_AUTH_SECRET=your-secret-key-here-change-this +BETTER_AUTH_URL=http://localhost:3000 + +# === Portas === +APP_PORT=3000 +DB_PORT=5432 + +# === Email (Opcional) === +# Provider: Resend (https://resend.com) +RESEND_API_KEY= +EMAIL_FROM=noreply@example.com + +# === OAuth (Opcional) === +# Google: https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +NEXT_PUBLIC_GOOGLE_OAUTH_ENABLED=true + +# === AI Providers (Opcional) === +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GOOGLE_GENERATIVE_AI_API_KEY= +OPENROUTER_API_KEY= \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..6b10a5b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f9a65d --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# ============================================ +# OPENSHEETS - .gitignore +# ============================================ + +# === Dependencies === +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# === Next.js === +/.next/ +/out/ +next-env.d.ts +.turbo + +# === Build === +/build +/dist +*.tsbuildinfo + +# === Testing === +/coverage +*.lcov + +# === Environment Variables === +# Ignora todos os .env exceto .env.example +.env* +!.env.example + +# === Logs === +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# === OS Files === +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*.swp +*.swo +*~ + +# === IDEs === +# VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# JetBrains (WebStorm, IntelliJ, etc) +.idea/ +*.iml +*.iws +*.ipr + +# Sublime Text +*.sublime-workspace +*.sublime-project + +# Vim +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# === Certificates === +*.pem +*.key +*.cert +*.crt + +# === Deploy Platforms === +.vercel +.netlify + +# === Database === +*.sqlite +*.sqlite3 +*.db + +# === Docker === +# Não ignora docker-compose.yml e Dockerfile +# Ignora apenas dados e logs locais +docker-compose.override.yml +*.log + +# === AI Assistants (Claude, Gemini, Cursor, etc) === +# Arquivos de configuração de assistentes de IA +.claude/ +.gemini/ +.cursor/ +CLAUDE.md +AGENTS.md +claude.md +agents.md + +# === Backups e Temporários === +*.bak +*.backup +*.tmp +*.temp +~$* + +# === Outros === +# Arquivos de lock temporários +package-lock.json # Se usa pnpm, não precisa do npm lock +yarn.lock # Se usa pnpm, não precisa do yarn lock + +# Drizzle Studio local cache +.drizzle/ + +# TypeScript cache +.tsbuildinfo + +# Local development files +.local/ +local/ +scratch/ +playground/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1371262 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/node_modules": true, + "node_modules": true, + "**/.vscode": true, + ".vscode": true, + "**/.next": true, + ".next": true + }, + "explorerExclude.backup": {}, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afa30de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,96 @@ +# Dockerfile para Next.js 16 com multi-stage build otimizado + +# ============================================ +# Stage 1: Instalação de dependências +# ============================================ +FROM node:22-alpine AS deps + +# Instalar pnpm globalmente +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +# Copiar apenas arquivos de dependências para aproveitar cache +COPY package.json pnpm-lock.yaml* ./ + +# Instalar dependências (production + dev para o build) +RUN pnpm install --frozen-lockfile + +# ============================================ +# Stage 2: Build da aplicação +# ============================================ +FROM node:22-alpine AS builder + +# Instalar pnpm globalmente +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +# Copiar dependências instaladas do stage anterior +COPY --from=deps /app/node_modules ./node_modules + +# Copiar todo o código fonte +COPY . . + +# 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 + +# Build da aplicação Next.js +# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts +RUN pnpm build + +# ============================================ +# Stage 3: Runtime (produção) +# ============================================ +FROM node:22-alpine AS runner + +# Instalar pnpm globalmente +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +# Criar usuário não-root para segurança +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 + +# Copiar arquivos de build do Next.js +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 + +# Copiar node_modules para ter drizzle-kit disponível para migrations +COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules + +# Definir variáveis de ambiente de produção +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME="0.0.0.0" + +# 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 + +# Comando de inicialização +# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..28d20ef --- /dev/null +++ b/README.md @@ -0,0 +1,1030 @@ +# OpenSheets + +> Uma aplicação moderna e completa construída com **Next.js 16**, **Better Auth**, **Drizzle ORM**, **PostgreSQL** e **shadcn/ui**. + +[![Next.js](https://img.shields.io/badge/Next.js-16-black?style=flat-square&logo=next.js)](https://nextjs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) +[![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=flat-square&logo=docker)](https://www.docker.com/) + +--- + +## 📖 Índice + +- [Sobre o Projeto](#-sobre-o-projeto) +- [Features](#-features) +- [Tech Stack](#-tech-stack) +- [Início Rápido](#-início-rápido) + - [Opção 1: Desenvolvimento Local (Recomendado para Devs)](#opção-1-desenvolvimento-local-recomendado-para-devs) + - [Opção 2: Docker Completo (Usuários Finais)](#opção-2-docker-completo-usuários-finais) + - [Opção 3: Docker + Banco Remoto](#opção-3-docker--banco-remoto) +- [Scripts Disponíveis](#-scripts-disponíveis) +- [Docker - Guia Detalhado](#-docker---guia-detalhado) +- [Configuração de Variáveis de Ambiente](#-configuração-de-variáveis-de-ambiente) +- [Banco de Dados](#-banco-de-dados) +- [Arquitetura](#-arquitetura) +- [Troubleshooting](#-troubleshooting) +- [Contribuindo](#-contribuindo) + +--- + +## 🎯 Sobre o Projeto + +**OpenSheets** é uma aplicação full-stack moderna projetada para controle de finanças pessoais. Construída com as melhores práticas de desenvolvimento e ferramentas de ponta, oferece uma base sólida e escalável para gestão financeira completa. + +### Por que usar o OpenSheets? + +- ✅ **Pronto para Produção** - Docker, health checks, migrations automáticas +- ✅ **TypeScript First** - Type safety em toda a aplicação +- ✅ **Autenticação Completa** - Better Auth com OAuth, email magic links +- ✅ **ORM Moderno** - Drizzle com Drizzle Studio integrado +- ✅ **UI Components** - shadcn/ui com design system completo +- ✅ **Developer Experience** - Hot reload, Turbopack, ESLint configurado + +--- + +## ✨ Features + +### 🔐 Autenticação + +- Better Auth integrado +- OAuth (Google, GitHub) +- Email magic links +- Session management +- Protected routes via middleware + +### 🗄️ Banco de Dados + +- PostgreSQL 18 (última versão estável) +- Drizzle ORM com TypeScript +- Migrations automáticas +- Drizzle Studio (UI visual para DB) +- Suporte para banco local (Docker) ou remoto (Supabase, Neon, etc) + +### 🎨 Interface + +- shadcn/ui components +- Tailwind CSS v4 +- Dark mode suportado +- Animações com Framer Motion + +### 🐳 Docker + +- Multi-stage build otimizado +- Health checks para app e banco +- Volumes persistentes +- Network isolada +- Scripts npm facilitados + +### 🧪 Desenvolvimento + +- Next.js 16 com App Router +- Turbopack (fast refresh) +- TypeScript 5.9 +- ESLint + Prettier +- React 19 + +--- + +## 🛠️ Tech Stack + +### Frontend + +- **Framework:** Next.js 16 (App Router) +- **Linguagem:** TypeScript 5.9 +- **UI Library:** React 19 +- **Styling:** Tailwind CSS v4 +- **Components:** shadcn/ui (Radix UI) +- **Icons:** Lucide React, Remixicon +- **Animations:** Framer Motion + +### Backend + +- **Runtime:** Node.js 22 +- **Database:** PostgreSQL 18 +- **ORM:** Drizzle ORM +- **Auth:** Better Auth +- **Email:** Resend + +### DevOps + +- **Containerization:** Docker + Docker Compose +- **Package Manager:** pnpm +- **Build Tool:** Turbopack + +### AI Integration (Opcional) + +- Anthropic (Claude) +- OpenAI (GPT) +- Google Gemini +- OpenRouter + +--- + +## 🚀 Início Rápido + +Escolha a opção que melhor se adequa ao seu caso: + +| Cenário | Quando usar | Comando principal | +| ----------- | ----------------------------------------- | -------------------------------------- | +| **Opção 1** | Você vai **desenvolver** e alterar código | `docker compose up db -d` + `pnpm dev` | +| **Opção 2** | Você só quer **usar** a aplicação | `pnpm docker:up` | +| **Opção 3** | Você já tem um **banco remoto** | `docker compose up app --build` | + +--- + +### Opção 1: Desenvolvimento Local (Recomendado para Devs) + +Esta é a **melhor opção para desenvolvedores** que vão modificar o código. + +#### Pré-requisitos + +- Node.js 22+ instalado +- pnpm instalado (ou npm/yarn) +- Docker e Docker Compose instalados + +#### Passo a Passo + +1. **Clone o repositório** + + ```bash + git clone https://github.com/felipegcoutinho/opensheets.git + cd opensheets + ``` + +2. **Instale as dependências** + + ```bash + pnpm install + ``` + +3. **Configure as variáveis de ambiente** + + ```bash + cp .env.example .env + ``` + + Edite o `.env` e configure: + + ```env + # Banco de dados (usando Docker) + DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db + DB_PROVIDER=local + + # Better Auth (gere com: openssl rand -base64 32) + BETTER_AUTH_SECRET=seu-secret-aqui + BETTER_AUTH_URL=http://localhost:3000 + ``` + +4. **Suba apenas o PostgreSQL em Docker** + + ```bash + docker compose up db -d + ``` + + Isso sobe **apenas o banco de dados** em container. A aplicação roda localmente. + +5. **Execute as migrations** + + ```bash + pnpm db:push + ``` + +6. **Inicie o servidor de desenvolvimento** + + ```bash + pnpm dev + ``` + +7. **Acesse a aplicação** + ``` + http://localhost:3000 + ``` + +#### Por que esta opção? + +- ✅ **Hot reload perfeito** - Mudanças no código refletem instantaneamente +- ✅ **Debugger funciona** - Use breakpoints normalmente +- ✅ **Menos recursos** - Só o banco roda em Docker +- ✅ **Drizzle Studio** - Acesse com `pnpm db:studio` +- ✅ **Melhor DX** - Developer Experience otimizada + +--- + +### Opção 2: Docker Completo (Usuários Finais) + +Ideal para quem quer apenas **usar a aplicação** sem mexer no código. + +#### Pré-requisitos + +- Docker e Docker Compose instalados + +#### Passo a Passo + +1. **Clone o repositório** + + ```bash + git clone https://github.com/felipegcoutinho/opensheets.git + cd opensheets + ``` + +2. **Configure as variáveis de ambiente** + + ```bash + cp .env.example .env + ``` + + Edite o `.env`: + + ```env + # Use o host "db" (nome do serviço Docker) + DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db + DB_PROVIDER=local + + # Better Auth + BETTER_AUTH_SECRET=seu-secret-aqui + BETTER_AUTH_URL=http://localhost:3000 + ``` + +3. **Suba tudo em Docker** + + ```bash + pnpm docker:up + # ou: docker compose up --build + ``` + + Isso sobe **aplicação + banco de dados** em containers. + +4. **Acesse a aplicação** + + ``` + http://localhost:3000 + ``` + +5. **Para parar** + ```bash + pnpm docker:down + # ou: docker compose down + ``` + +#### Dicas + +- Use `pnpm docker:up:detached` para rodar em background +- Veja logs com `pnpm docker:logs` +- Reinicie com `pnpm docker:restart` + +--- + +### Opção 3: Docker + Banco Remoto + +Se você já tem PostgreSQL no **Supabase**, **Neon**, **Railway**, etc. + +#### Passo a Passo + +1. **Configure o `.env` com banco remoto** + + ```env + DATABASE_URL=postgresql://user:password@host.region.provider.com:5432/database?sslmode=require + DB_PROVIDER=remote + + BETTER_AUTH_SECRET=seu-secret-aqui + BETTER_AUTH_URL=http://localhost:3000 + ``` + +2. **Suba apenas a aplicação** + + ```bash + docker compose up app --build + ``` + +3. **Acesse a aplicação** + ``` + http://localhost:3000 + ``` + +--- + +## 📜 Scripts Disponíveis + +### Desenvolvimento + +```bash +# Servidor de desenvolvimento (com Turbopack) +pnpm dev + +# Build de produção +pnpm build + +# Servidor de produção +pnpm start + +# Linter +pnpm lint +``` + +### Banco de Dados (Drizzle) + +```bash +# Gerar migrations a partir do schema +pnpm db:generate + +# Executar migrations +pnpm db:migrate + +# Push schema direto para o banco (dev only) +pnpm db:push + +# Abrir Drizzle Studio (UI visual do banco) +pnpm db:studio +``` + +### Docker + +```bash +# Subir todos os containers (app + banco) +pnpm docker:up + +# Subir em background (detached mode) +pnpm docker:up:detached + +# Parar todos os containers +pnpm docker:down + +# Parar e REMOVER volumes (⚠️ apaga dados do banco!) +pnpm docker:down:volumes + +# Ver logs em tempo real +pnpm docker:logs + +# Logs apenas da aplicação +pnpm docker:logs:app + +# Logs apenas do banco de dados +pnpm docker:logs:db + +# Reiniciar containers +pnpm docker:restart + +# Rebuild completo (força reconstrução) +pnpm docker:rebuild +``` + +### Utilitários + +```bash +# Setup automático de variáveis de ambiente +pnpm env:setup +``` + +--- + +## 🐳 Docker - Guia Detalhado + +### Arquitetura Docker + +``` +┌─────────────────────────────────────────────────┐ +│ docker-compose.yml │ +├─────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌─────────────────┐ │ +│ │ app │ │ db │ │ +│ │ (Next.js 16) │◄─────┤ (PostgreSQL 18)│ │ +│ │ Port: 3000 │ │ Port: 5432 │ │ +│ │ Node.js 22 │ │ Alpine Linux │ │ +│ └──────────────────┘ └─────────────────┘ │ +│ │ +│ Network: opensheets_network (bridge) │ +│ Volume: opensheets_postgres_data (persistent) │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +### Multi-Stage Build + +O `Dockerfile` usa **3 stages** para otimização: + +1. **deps** - Instala dependências +2. **builder** - Builda a aplicação (Next.js standalone) +3. **runner** - Imagem final mínima (apenas produção) + +**Benefícios:** + +- Imagem final **muito menor** (~200MB vs ~1GB) +- Build cache eficiente +- Apenas dependências de produção no final +- Security: roda como usuário não-root + +### Health Checks + +Ambos os serviços têm health checks: + +**PostgreSQL:** + +- Comando: `pg_isready` +- Intervalo: 10s +- Timeout: 5s + +**Next.js App:** + +- Endpoint: `http://localhost:3000/api/health` +- Intervalo: 30s +- Start period: 40s (aguarda build) + +### Volumes e Persistência + +```yaml +volumes: + postgres_data: + name: opensheets_postgres_data + driver: local +``` + +- Os dados do PostgreSQL **persistem** entre restarts +- Para **apagar dados**: `pnpm docker:down:volumes` +- Para **backup**: `docker compose exec db pg_dump...` + +### Network Isolada + +```yaml +networks: + opensheets_network: + name: opensheets_network + driver: bridge +``` + +- App e banco se comunicam via network interna +- Isolamento de segurança +- DNS automático (app acessa `db:5432`) + +### Comandos Docker Avançados + +```bash +# Entrar no container da aplicação +docker compose exec app sh + +# Entrar no container do banco +docker compose exec db psql -U opensheets -d opensheets_db + +# Ver status dos containers +docker compose ps + +# Ver uso de recursos +docker stats opensheets_app opensheets_postgres + +# Backup do banco +docker compose exec db pg_dump -U opensheets opensheets_db > backup.sql + +# Restaurar backup +docker compose exec -T db psql -U opensheets -d opensheets_db < backup.sql + +# Limpar tudo (containers, volumes, images) +docker compose down -v +docker system prune -a +``` + +### Customizando Portas + +No arquivo `.env`: + +```env +# Porta da aplicação (padrão: 3000) +APP_PORT=3001 + +# Porta do banco de dados (padrão: 5432) +DB_PORT=5433 +``` + +--- + +## 🔐 Configuração de Variáveis de Ambiente + +Copie o `.env.example` para `.env` e configure: + +### Variáveis Obrigatórias + +```env +# === Database === +DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db +DB_PROVIDER=local # ou "remote" + +# === Better Auth === +# Gere com: openssl rand -base64 32 +BETTER_AUTH_SECRET=seu-secret-super-secreto-aqui +BETTER_AUTH_URL=http://localhost:3000 +``` + +### Variáveis Opcionais + +#### PostgreSQL (customização) + +```env +POSTGRES_USER=opensheets +POSTGRES_PASSWORD=opensheets_dev_password +POSTGRES_DB=opensheets_db +``` + +#### Portas (customização) + +```env +APP_PORT=3000 +DB_PORT=5432 +``` + +#### OAuth Providers + +```env +GOOGLE_CLIENT_ID=seu-google-client-id +GOOGLE_CLIENT_SECRET=seu-google-client-secret + +GITHUB_CLIENT_ID=seu-github-client-id +GITHUB_CLIENT_SECRET=seu-github-client-secret +``` + +#### Email (Resend) + +```env +RESEND_API_KEY=re_seu_api_key +EMAIL_FROM=noreply@seudominio.com +``` + +#### AI Providers + +```env +ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +GOOGLE_GENERATIVE_AI_API_KEY=... +OPENROUTER_API_KEY=sk-or-... +``` + +### Gerando Secrets + +```bash +# BETTER_AUTH_SECRET +openssl rand -base64 32 + +# Ou use o script automático +pnpm env:setup +``` + +--- + +## 🗄️ Banco de Dados + +### Escolhendo entre Local e Remoto + +| Modo | Quando usar | Como configurar | +| ---------- | ------------------------------------- | -------------------------------------- | +| **Local** | Desenvolvimento, testes, prototipagem | `DB_PROVIDER=local` + Docker | +| **Remoto** | Produção, deploy, banco gerenciado | `DB_PROVIDER=remote` + URL do provider | + +### Drizzle ORM + +#### Schema Definition + +Os schemas ficam em `/db/schema.ts`: + +```typescript +import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), + name: text("name"), + createdAt: timestamp("created_at").defaultNow(), +}); +``` + +#### Gerando Migrations + +```bash +# Após alterar /db/schema.ts +pnpm db:generate + +# Aplica migrations +pnpm db:migrate + +# Ou push direto (dev only) +pnpm db:push +``` + +#### Drizzle Studio + +Interface visual para explorar e editar dados: + +```bash +pnpm db:studio +``` + +Abre em: `https://local.drizzle.studio` + +### Migrations Automáticas (Docker) + +No `docker-compose.yml`, migrations rodam automaticamente: + +```yaml +command: + - | + echo "📦 Rodando migrations..." + pnpm db:push + + echo "✅ Iniciando aplicação..." + node server.js +``` + +### Backup e Restore + +```bash +# Backup (banco local Docker) +docker compose exec db pg_dump -U opensheets opensheets_db > backup_$(date +%Y%m%d).sql + +# Backup (banco remoto) +pg_dump $DATABASE_URL > backup.sql + +# Restore (Docker) +docker compose exec -T db psql -U opensheets -d opensheets_db < backup.sql + +# Restore (remoto) +psql $DATABASE_URL < backup.sql +``` + +--- + +## 🏗️ Arquitetura + +### Estrutura de Pastas + +``` +opensheets/ +├── app/ # Next.js App Router +│ ├── api/ # API Routes +│ │ ├── auth/ # Better Auth endpoints +│ │ └── health/ # Health check +│ ├── (dashboard)/ # Protected routes (com auth) +│ └── layout.tsx # Root layout +│ +├── components/ # React Components +│ ├── ui/ # shadcn/ui components +│ └── ... # Feature components +│ +├── lib/ # Shared utilities +│ ├── db.ts # Drizzle client +│ ├── auth.ts # Better Auth server +│ └── auth-client.ts # Better Auth client +│ +├── db/ # Drizzle schema +│ └── schema.ts # Database schema +│ +├── drizzle/ # Generated migrations +│ └── migrations/ +│ +├── hooks/ # Custom React hooks +├── public/ # Static assets +├── scripts/ # Utility scripts +│ ├── setup-env.sh # Env setup automation +│ └── postgres/init.sql # PostgreSQL init script +│ +├── docker/ # Docker configs +│ └── postgres/init.sql +│ +├── Dockerfile # Production build +├── docker-compose.yml # Docker orchestration +├── next.config.ts # Next.js config +├── drizzle.config.ts # Drizzle ORM config +├── tailwind.config.ts # Tailwind config +└── tsconfig.json # TypeScript config +``` + +### Fluxo de Autenticação + +``` +1. Usuário acessa rota protegida + ↓ +2. middleware.ts verifica sessão (Better Auth) + ↓ +3. Se não autenticado → redirect /auth + ↓ +4. Usuário faz login (OAuth ou email) + ↓ +5. Better Auth valida e cria sessão + ↓ +6. Cookie de sessão é salvo + ↓ +7. Usuário acessa rota protegida ✅ +``` + +### Fluxo de Build (Docker) + +``` +1. Stage deps: Instala dependências + ↓ +2. Stage builder: Builda Next.js (standalone) + ↓ +3. Stage runner: Copia apenas build + deps prod + ↓ +4. Container final: ~200MB (otimizado) +``` + +--- + +## 🆘 Troubleshooting + +### Erro: "DATABASE_URL env variable is not set" + +**Causa:** Arquivo `.env` não existe ou `DATABASE_URL` não configurado + +**Solução:** + +```bash +cp .env.example .env +# Edite .env e configure DATABASE_URL +``` + +--- + +### Container do app não conecta ao banco + +**Causa:** `DATABASE_URL` usa `localhost` em vez de `db` + +**Solução:** + +Para Docker, use o **nome do serviço**: + +```env +# ❌ Errado (localhost não funciona dentro do container) +DATABASE_URL=postgresql://opensheets:senha@localhost:5432/opensheets_db + +# ✅ Correto (usa nome do serviço Docker) +DATABASE_URL=postgresql://opensheets:senha@db:5432/opensheets_db +``` + +Para desenvolvimento local (sem Docker app): + +```env +# ✅ Correto (app roda local, banco em Docker) +DATABASE_URL=postgresql://opensheets:senha@localhost:5432/opensheets_db +``` + +**Verifique o status do banco:** + +```bash +docker compose ps +docker compose logs db +``` + +--- + +### Porta 3000 ou 5432 já está em uso + +**Solução:** + +Edite o `.env`: + +```env +APP_PORT=3001 +DB_PORT=5433 +``` + +Ou pare o processo que está usando: + +```bash +# Descobrir quem usa a porta +lsof -i :3000 +lsof -i :5432 + +# Matar processo +kill -9 +``` + +--- + +### Migrations não rodam + +**Com Docker:** + +Migrations rodam automaticamente no startup. Veja logs: + +```bash +pnpm docker:logs:app +``` + +Se falharem, rode manualmente: + +```bash +docker compose exec app pnpm db:push +``` + +**Sem Docker:** + +```bash +pnpm db:push +``` + +--- + +### Erro: "server.js not found" + +**Causa:** Next.js não gerou standalone build + +**Solução:** + +1. Verifique `next.config.ts`: + +```typescript +const nextConfig: NextConfig = { + output: "standalone", // ← Deve estar presente +}; +``` + +2. Rebuild: + +```bash +docker compose down +docker compose up --build +``` + +--- + +### Erro ao atualizar PostgreSQL 16 → 18 + +**Causa:** Volumes antigos são incompatíveis + +**Solução:** + +```bash +# ⚠️ ATENÇÃO: Isso apaga dados do banco local! +docker compose down -v + +# Suba novamente com PostgreSQL 18 +docker compose up --build +``` + +**Para preservar dados:** + +```bash +# 1. Backup +docker compose exec db pg_dumpall -U opensheets > backup.sql + +# 2. Limpa volumes +docker compose down -v + +# 3. Sobe PG 18 +docker compose up -d db + +# 4. Aguarda (15s) +sleep 15 + +# 5. Restaura +docker compose exec -T db psql -U opensheets -d opensheets_db < backup.sql +``` + +--- + +### Drizzle Studio não abre + +**Solução:** + +1. Verifique se o banco está rodando: + +```bash +docker compose ps +``` + +2. Teste conexão: + +```bash +psql $DATABASE_URL +``` + +3. Abra Drizzle Studio: + +```bash +pnpm db:studio +``` + +--- + +### Build do Docker muito lento + +**Causa:** Cache não está sendo aproveitado + +**Solução:** + +1. Use BuildKit: + +```bash +export DOCKER_BUILDKIT=1 +docker compose build +``` + +2. Limpe cache antigo: + +```bash +docker builder prune +``` + +3. Multi-stage build já otimiza camadas + +--- + +### "Permission denied" ao rodar Docker + +**Causa:** Usuário não está no grupo docker + +**Solução (Linux):** + +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +**Solução (Mac/Windows):** + +- Docker Desktop deve estar rodando +- Verifique configurações de permissão + +--- + +### Limpar tudo e começar do zero + +```bash +# Para containers e remove volumes +docker compose down -v + +# Remove images não usadas +docker system prune -a + +# Remove TUDO do Docker (cuidado!) +docker system prune -a --volumes + +# Rebuild do zero +pnpm docker:up +``` + +--- + +## 🤝 Contribuindo + +Contribuições são muito bem-vindas! + +### Como contribuir + +1. **Fork** o projeto +2. **Clone** seu fork + ```bash + git clone https://github.com/seu-usuario/opensheets.git + ``` +3. **Crie uma branch** para sua feature + ```bash + git checkout -b feature/minha-feature + ``` +4. **Commit** suas mudanças + ```bash + git commit -m 'feat: adiciona minha feature' + ``` +5. **Push** para a branch + ```bash + git push origin feature/minha-feature + ``` +6. Abra um **Pull Request** + +### Padrões + +- Use **TypeScript** +- Siga o **ESLint** configurado +- Documente **features novas** +- Use **commits semânticos** (feat, fix, docs, etc) + +--- + +## 📄 Licença + +Este projeto é open source e está disponível sob a [Licença MIT](LICENSE). + +--- + +## 🙏 Agradecimentos + +- [Next.js](https://nextjs.org/) +- [Better Auth](https://better-auth.com/) +- [Drizzle ORM](https://orm.drizzle.team/) +- [shadcn/ui](https://ui.shadcn.com/) +- [Vercel](https://vercel.com/) + +--- + +## 📞 Contato + +**Desenvolvido por:** Felipe Coutinho +**GitHub:** [@felipegcoutinho](https://github.com/felipegcoutinho) +**Repositório:** [opensheets](https://github.com/felipegcoutinho/opensheets) + +--- + +
+ +**⭐ Se este projeto foi útil, considere dar uma estrela!** + +Desenvolvido com ❤️ para a comunidade open source + +
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..8e70c5e --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,11 @@ +import { LoginForm } from "@/components/auth/login-form"; + +export default function LoginPage() { + return ( +
+
+ +
+
+ ); +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..8e4c66e --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,11 @@ +import { SignupForm } from "@/components/auth/signup-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts new file mode 100644 index 0000000..457abda --- /dev/null +++ b/app/(dashboard)/ajustes/actions.ts @@ -0,0 +1,257 @@ +"use server"; + +import { auth } from "@/lib/auth/config"; +import { db, schema } from "@/lib/db"; +import { eq, and, ne } from "drizzle-orm"; +import { headers } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +type ActionResponse = { + success: boolean; + message?: string; + error?: string; + data?: T; +}; + +// Schema de validação +const updateNameSchema = z.object({ + firstName: z.string().min(1, "Primeiro nome é obrigatório"), + lastName: z.string().min(1, "Sobrenome é obrigatório"), +}); + +const updatePasswordSchema = z + .object({ + newPassword: z.string().min(6, "A senha deve ter no mínimo 6 caracteres"), + confirmPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "As senhas não coincidem", + path: ["confirmPassword"], + }); + +const updateEmailSchema = z + .object({ + newEmail: z.string().email("E-mail inválido"), + confirmEmail: z.string().email("E-mail inválido"), + }) + .refine((data) => data.newEmail === data.confirmEmail, { + message: "Os e-mails não coincidem", + path: ["confirmEmail"], + }); + +const deleteAccountSchema = z.object({ + confirmation: z.literal("DELETAR", { + errorMap: () => ({ message: 'Você deve digitar "DELETAR" para confirmar' }), + }), +}); + +// Actions + +export async function updateNameAction( + data: z.infer +): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } + + const validated = updateNameSchema.parse(data); + const fullName = `${validated.firstName} ${validated.lastName}`; + + await db + .update(schema.user) + .set({ name: fullName }) + .where(eq(schema.user.id, session.user.id)); + + // Revalidar o layout do dashboard para atualizar a sidebar + revalidatePath("/", "layout"); + + return { + success: true, + message: "Nome atualizado com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao atualizar nome:", error); + return { + success: false, + error: "Erro ao atualizar nome. Tente novamente.", + }; + } +} + +export async function updatePasswordAction( + data: z.infer +): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id || !session?.user?.email) { + return { + success: false, + error: "Não autenticado", + }; + } + + const validated = updatePasswordSchema.parse(data); + + // Usar a API do Better Auth para atualizar a senha + try { + await auth.api.changePassword({ + body: { + newPassword: validated.newPassword, + currentPassword: "", // Better Auth pode não exigir a senha atual dependendo da configuração + }, + headers: await headers(), + }); + + return { + success: true, + message: "Senha atualizada com sucesso", + }; + } catch (authError) { + console.error("Erro na API do Better Auth:", authError); + // Se a API do Better Auth falhar, retornar erro genérico + return { + success: false, + error: "Erro ao atualizar senha. Tente novamente.", + }; + } + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao atualizar senha:", error); + return { + success: false, + error: "Erro ao atualizar senha. Tente novamente.", + }; + } +} + +export async function updateEmailAction( + data: z.infer +): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } + + const validated = updateEmailSchema.parse(data); + + // Verificar se o e-mail já está em uso por outro usuário + const existingUser = await db.query.user.findFirst({ + where: and( + eq(schema.user.email, validated.newEmail), + ne(schema.user.id, session.user.id) + ), + }); + + if (existingUser) { + return { + success: false, + error: "Este e-mail já está em uso", + }; + } + + // Atualizar e-mail + await db + .update(schema.user) + .set({ + email: validated.newEmail, + emailVerified: false, // Marcar como não verificado + }) + .where(eq(schema.user.id, session.user.id)); + + // Revalidar o layout do dashboard para atualizar a sidebar + revalidatePath("/", "layout"); + + return { + success: true, + message: + "E-mail atualizado com sucesso. Por favor, verifique seu novo e-mail.", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao atualizar e-mail:", error); + return { + success: false, + error: "Erro ao atualizar e-mail. Tente novamente.", + }; + } +} + +export async function deleteAccountAction( + data: z.infer +): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } + + // Validar confirmação + deleteAccountSchema.parse(data); + + // Deletar todos os dados do usuário em cascade + // O schema deve ter as relações configuradas com onDelete: cascade + await db.delete(schema.user).where(eq(schema.user.id, session.user.id)); + + return { + success: true, + message: "Conta deletada com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao deletar conta:", error); + return { + success: false, + error: "Erro ao deletar conta. Tente novamente.", + }; + } +} diff --git a/app/(dashboard)/ajustes/layout.tsx b/app/(dashboard)/ajustes/layout.tsx new file mode 100644 index 0000000..ac0bb2a --- /dev/null +++ b/app/(dashboard)/ajustes/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiSettingsLine } from "@remixicon/react"; + +export const metadata = { + title: "Ajustes | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Ajustes" + subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx new file mode 100644 index 0000000..0296d65 --- /dev/null +++ b/app/(dashboard)/ajustes/page.tsx @@ -0,0 +1,85 @@ +import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; +import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; +import { UpdateNameForm } from "@/components/ajustes/update-name-form"; +import { UpdatePasswordForm } from "@/components/ajustes/update-password-form"; +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { auth } from "@/lib/auth/config"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export default async function Page() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + redirect("/"); + } + + const userName = session.user.name || ""; + const userEmail = session.user.email || ""; + + return ( +
+ + + Altere seu nome + Alterar senha + Alterar e-mail + + Deletar conta + + + + + +
+

Alterar nome

+

+ Atualize como seu nome aparece no OpenSheets. Esse nome pode ser + exibido em diferentes seções do app e em comunicações. +

+
+ +
+ + +
+

Alterar senha

+

+ Defina uma nova senha para sua conta. Guarde-a em local seguro. +

+
+ +
+ + +
+

Alterar e-mail

+

+ Atualize o e-mail associado à sua conta. Você precisará + confirmar os links enviados para o novo e também para o e-mail + atual (quando aplicável) para concluir a alteração. +

+
+ +
+ + +
+

+ Deletar conta +

+

+ Ao prosseguir, sua conta e todos os dados associados serão + excluídos de forma irreversível. +

+
+ +
+
+
+
+ ); +} diff --git a/app/(dashboard)/anotacoes/actions.ts b/app/(dashboard)/anotacoes/actions.ts new file mode 100644 index 0000000..d4646e2 --- /dev/null +++ b/app/(dashboard)/anotacoes/actions.ts @@ -0,0 +1,144 @@ +"use server"; + +import { anotacoes } from "@/db/schema"; +import { type ActionResult, handleActionError } from "@/lib/actions/helpers"; +import { revalidateForEntity } from "@/lib/actions/helpers"; +import { uuidSchema } from "@/lib/schemas/common"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +const taskSchema = z.object({ + id: z.string(), + text: z.string().min(1, "O texto da tarefa não pode estar vazio."), + completed: z.boolean(), +}); + +const noteBaseSchema = z.object({ + title: z + .string({ message: "Informe o título da anotação." }) + .trim() + .min(1, "Informe o título da anotação.") + .max(30, "O título deve ter no máximo 30 caracteres."), + description: z + .string({ message: "Informe o conteúdo da anotação." }) + .trim() + .max(350, "O conteúdo deve ter no máximo 350 caracteres.") + .optional() + .default(""), + type: z.enum(["nota", "tarefa"], { + message: "O tipo deve ser 'nota' ou 'tarefa'.", + }), + tasks: z.array(taskSchema).optional().default([]), +}).refine( + (data) => { + // Se for nota, a descrição é obrigatória + if (data.type === "nota") { + return data.description.trim().length > 0; + } + // Se for tarefa, deve ter pelo menos uma tarefa + if (data.type === "tarefa") { + return data.tasks && data.tasks.length > 0; + } + return true; + }, + { + message: "Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.", + } +); + +const createNoteSchema = noteBaseSchema; +const updateNoteSchema = noteBaseSchema.and(z.object({ + id: uuidSchema("Anotação"), +})); +const deleteNoteSchema = z.object({ + id: uuidSchema("Anotação"), +}); + +type NoteCreateInput = z.infer; +type NoteUpdateInput = z.infer; +type NoteDeleteInput = z.infer; + +export async function createNoteAction( + input: NoteCreateInput +): Promise { + try { + const user = await getUser(); + const data = createNoteSchema.parse(input); + + await db.insert(anotacoes).values({ + title: data.title, + description: data.description, + type: data.type, + tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null, + userId: user.id, + }); + + revalidateForEntity("anotacoes"); + + return { success: true, message: "Anotação criada com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updateNoteAction( + input: NoteUpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateNoteSchema.parse(input); + + const [updated] = await db + .update(anotacoes) + .set({ + title: data.title, + description: data.description, + type: data.type, + tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null, + }) + .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) + .returning({ id: anotacoes.id }); + + if (!updated) { + return { + success: false, + error: "Anotação não encontrada.", + }; + } + + revalidateForEntity("anotacoes"); + + return { success: true, message: "Anotação atualizada com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteNoteAction( + input: NoteDeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteNoteSchema.parse(input); + + const [deleted] = await db + .delete(anotacoes) + .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) + .returning({ id: anotacoes.id }); + + if (!deleted) { + return { + success: false, + error: "Anotação não encontrada.", + }; + } + + revalidateForEntity("anotacoes"); + + return { success: true, message: "Anotação removida com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/anotacoes/data.ts b/app/(dashboard)/anotacoes/data.ts new file mode 100644 index 0000000..8128a24 --- /dev/null +++ b/app/(dashboard)/anotacoes/data.ts @@ -0,0 +1,48 @@ +import { anotacoes, type Anotacao } from "@/db/schema"; +import { db } from "@/lib/db"; +import { eq } from "drizzle-orm"; + +export type Task = { + id: string; + text: string; + completed: boolean; +}; + +export type NoteData = { + id: string; + title: string; + description: string; + type: "nota" | "tarefa"; + tasks?: Task[]; + createdAt: string; +}; + +export async function fetchNotesForUser(userId: string): Promise { + const noteRows = await db.query.anotacoes.findMany({ + where: eq(anotacoes.userId, userId), + 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, + createdAt: note.createdAt.toISOString(), + }; + }); +} diff --git a/app/(dashboard)/anotacoes/layout.tsx b/app/(dashboard)/anotacoes/layout.tsx new file mode 100644 index 0000000..8e1c66b --- /dev/null +++ b/app/(dashboard)/anotacoes/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiFileListLine } from "@remixicon/react"; + +export const metadata = { + title: "Anotações | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Notas" + subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/anotacoes/loading.tsx b/app/(dashboard)/anotacoes/loading.tsx new file mode 100644 index 0000000..7ee612f --- /dev/null +++ b/app/(dashboard)/anotacoes/loading.tsx @@ -0,0 +1,51 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de anotações + * Layout: Header com botão + Grid de cards de notas + */ +export default function AnotacoesLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de cards de notas */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Título */} + + + {/* Conteúdo (3-4 linhas) */} +
+ + + + {i % 2 === 0 && ( + + )} +
+ + {/* Footer com data e ações */} +
+ +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/anotacoes/page.tsx b/app/(dashboard)/anotacoes/page.tsx new file mode 100644 index 0000000..5dfae8f --- /dev/null +++ b/app/(dashboard)/anotacoes/page.tsx @@ -0,0 +1,14 @@ +import { NotesPage } from "@/components/anotacoes/notes-page"; +import { getUserId } from "@/lib/auth/server"; +import { fetchNotesForUser } from "./data"; + +export default async function Page() { + const userId = await getUserId(); + const notes = await fetchNotesForUser(userId); + + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/calendario/data.ts b/app/(dashboard)/calendario/data.ts new file mode 100644 index 0000000..65cfc9c --- /dev/null +++ b/app/(dashboard)/calendario/data.ts @@ -0,0 +1,212 @@ +import { cartoes, lancamentos } from "@/db/schema"; +import { + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, + mapLancamentosData, +} from "@/lib/lancamentos/page-helpers"; +import { db } from "@/lib/db"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +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"; + +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 => { + 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(); + 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 = { + 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, + }, + }; +}; diff --git a/app/(dashboard)/calendario/layout.tsx b/app/(dashboard)/calendario/layout.tsx new file mode 100644 index 0000000..3e1f720 --- /dev/null +++ b/app/(dashboard)/calendario/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiCalendarEventLine } from "@remixicon/react"; + +export const metadata = { + title: "Calendário | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Calendário" + subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/calendario/loading.tsx b/app/(dashboard)/calendario/loading.tsx new file mode 100644 index 0000000..79ad556 --- /dev/null +++ b/app/(dashboard)/calendario/loading.tsx @@ -0,0 +1,59 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de calendário + * Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos + */ +export default function CalendarioLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ + {/* Calendar Container */} +
+ {/* Cabeçalho com dias da semana */} +
+ {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => ( +
+ +
+ ))} +
+ + {/* Grade de dias (6 semanas) */} +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+ {/* Número do dia */} + + + {/* Indicadores de eventos (aleatório entre 0-3) */} + {i % 3 === 0 && ( +
+ + {i % 5 === 0 && ( + + )} +
+ )} +
+ ))} +
+ + {/* Legenda */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/calendario/page.tsx b/app/(dashboard)/calendario/page.tsx new file mode 100644 index 0000000..fa24c4b --- /dev/null +++ b/app/(dashboard)/calendario/page.tsx @@ -0,0 +1,47 @@ +import MonthPicker from "@/components/month-picker/month-picker"; +import { getUserId } from "@/lib/auth/server"; +import { + getSingleParam, + type ResolvedSearchParams, +} from "@/lib/lancamentos/page-helpers"; +import { parsePeriodParam } from "@/lib/utils/period"; + +import { MonthlyCalendar } from "@/components/calendario/monthly-calendar"; +import { fetchCalendarData } from "./data"; +import type { CalendarPeriod } from "@/components/calendario/types"; + +type PageSearchParams = Promise; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +export default async function Page({ searchParams }: PageProps) { + const userId = await getUserId(); + const resolvedParams = searchParams ? await searchParams : undefined; + + const periodoParam = getSingleParam(resolvedParams, "periodo"); + const { period, monthName, year } = parsePeriodParam(periodoParam); + + const calendarData = await fetchCalendarData({ + userId, + period, + }); + + const calendarPeriod: CalendarPeriod = { + period, + monthName, + year, + }; + + return ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts new file mode 100644 index 0000000..eb4e70e --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts @@ -0,0 +1,299 @@ +"use server"; + +import { + cartoes, + categorias, + faturas, + lancamentos, + pagadores, +} from "@/db/schema"; +import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { + INVOICE_PAYMENT_STATUS, + INVOICE_STATUS_VALUES, + PERIOD_FORMAT_REGEX, + type InvoicePaymentStatus, +} from "@/lib/faturas"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { and, eq, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +const updateInvoicePaymentStatusSchema = z.object({ + cartaoId: z + .string({ message: "Cartão inválido." }) + .uuid("Cartão inválido."), + period: z + .string({ message: "Período inválido." }) + .regex(PERIOD_FORMAT_REGEX, "Período inválido."), + status: z.enum( + INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]] + ), + paymentDate: z.string().optional(), +}); + +type UpdateInvoicePaymentStatusInput = z.infer< + typeof updateInvoicePaymentStatusSchema +>; + +type ActionResult = + | { success: true; message: string } + | { success: false; error: string }; + +const successMessageByStatus: Record = { + [INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.", + [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", +}; + +const formatDecimal = (value: number) => + (Math.round(value * 100) / 100).toFixed(2); + +export async function updateInvoicePaymentStatusAction( + input: UpdateInvoicePaymentStatusInput +): Promise { + try { + const user = await getUser(); + const data = updateInvoicePaymentStatusSchema.parse(input); + + await db.transaction(async (tx: typeof db) => { + const card = await tx.query.cartoes.findFirst({ + columns: { id: true, contaId: true, name: true }, + where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), + }); + + if (!card) { + throw new Error("Cartão não encontrado."); + } + + const existingInvoice = await tx.query.faturas.findFirst({ + columns: { + id: true, + }, + where: and( + eq(faturas.cartaoId, data.cartaoId), + eq(faturas.userId, user.id), + eq(faturas.period, data.period) + ), + }); + + if (existingInvoice) { + await tx + .update(faturas) + .set({ + paymentStatus: data.status, + }) + .where(eq(faturas.id, existingInvoice.id)); + } else { + await tx.insert(faturas).values({ + cartaoId: data.cartaoId, + period: data.period, + paymentStatus: data.status, + userId: user.id, + }); + } + + const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID; + + await tx + .update(lancamentos) + .set({ isSettled: shouldMarkAsPaid }) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.cartaoId, card.id), + eq(lancamentos.period, data.period) + ) + ); + + const invoiceNote = buildInvoicePaymentNote(card.id, data.period); + + if (shouldMarkAsPaid) { + const [adminShareRow] = await tx + .select({ + total: sql` + coalesce( + sum( + case + when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount} + else 0 + end + ), + 0 + ) + `, + }) + .from(lancamentos) + .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.cartaoId, card.id), + eq(lancamentos.period, data.period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ); + + const adminShare = Math.abs(Number(adminShareRow?.total ?? 0)); + + if (adminShare > 0 && card.contaId) { + const adminPagador = await tx.query.pagadores.findFirst({ + columns: { id: true }, + where: and( + eq(pagadores.userId, user.id), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ), + }); + + const paymentCategory = await tx.query.categorias.findFirst({ + columns: { id: true }, + where: and( + eq(categorias.userId, user.id), + eq(categorias.name, "Pagamentos") + ), + }); + + if (adminPagador) { + // Usar a data customizada ou a data atual como data de pagamento + const invoiceDate = data.paymentDate + ? new Date(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) + ) + ); + } + }); + + revalidatePath(`/cartoes/${data.cartaoId}/fatura`); + revalidatePath("/cartoes"); + revalidatePath("/contas"); + + return { success: true, message: successMessageByStatus[data.status] }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : "Erro inesperado.", + }; + } +} + +const updatePaymentDateSchema = z.object({ + cartaoId: z + .string({ message: "Cartão inválido." }) + .uuid("Cartão inválido."), + period: z + .string({ message: "Período inválido." }) + .regex(PERIOD_FORMAT_REGEX, "Período inválido."), + paymentDate: z.string({ message: "Data de pagamento inválida." }), +}); + +type UpdatePaymentDateInput = z.infer; + +export async function updatePaymentDateAction( + input: UpdatePaymentDateInput +): Promise { + try { + const user = await getUser(); + const data = updatePaymentDateSchema.parse(input); + + await db.transaction(async (tx: typeof db) => { + const card = await tx.query.cartoes.findFirst({ + columns: { id: true }, + where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), + }); + + if (!card) { + throw new Error("Cartão não encontrado."); + } + + const invoiceNote = buildInvoicePaymentNote(card.id, data.period); + + const existingPayment = await tx.query.lancamentos.findFirst({ + columns: { id: true }, + where: and( + eq(lancamentos.userId, user.id), + eq(lancamentos.note, invoiceNote) + ), + }); + + if (!existingPayment) { + throw new Error("Pagamento não encontrado."); + } + + await tx + .update(lancamentos) + .set({ + purchaseDate: new Date(data.paymentDate), + }) + .where(eq(lancamentos.id, existingPayment.id)); + }); + + revalidatePath(`/cartoes/${data.cartaoId}/fatura`); + revalidatePath("/cartoes"); + revalidatePath("/contas"); + + return { success: true, message: "Data de pagamento atualizada." }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : "Erro inesperado.", + }; + } +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts new file mode 100644 index 0000000..bf4ec97 --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts @@ -0,0 +1,104 @@ +import { cartoes, faturas, lancamentos } from "@/db/schema"; +import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; +import { db } from "@/lib/db"; +import { + INVOICE_PAYMENT_STATUS, + type InvoicePaymentStatus, +} from "@/lib/faturas"; +import { and, eq, sum } from "drizzle-orm"; + +const toNumber = (value: string | number | null | undefined) => { + if (typeof value === "number") { + return value; + } + if (value === null || value === undefined) { + return 0; + } + const parsed = Number(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +export async function fetchCardData(userId: string, cartaoId: string) { + const card = await db.query.cartoes.findFirst({ + columns: { + id: true, + name: true, + brand: true, + closingDay: true, + dueDay: true, + logo: true, + limit: true, + status: true, + note: true, + contaId: true, + }, + where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)), + }); + + return card; +} + +export async function fetchInvoiceData( + userId: string, + cartaoId: string, + selectedPeriod: string +): Promise<{ + totalAmount: number; + invoiceStatus: InvoicePaymentStatus; + paymentDate: Date | null; +}> { + const [invoiceRow, totalRow] = await Promise.all([ + db.query.faturas.findFirst({ + columns: { + id: true, + period: true, + paymentStatus: true, + }, + where: and( + eq(faturas.cartaoId, cartaoId), + eq(faturas.userId, userId), + eq(faturas.period, selectedPeriod) + ), + }), + db + .select({ totalAmount: sum(lancamentos.amount) }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.cartaoId, cartaoId), + eq(lancamentos.period, selectedPeriod) + ) + ), + ]); + + const totalAmount = toNumber(totalRow[0]?.totalAmount); + const isInvoiceStatus = ( + value: string | null | undefined + ): value is InvoicePaymentStatus => + !!value && ["pendente", "pago"].includes(value); + + const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus) + ? invoiceRow?.paymentStatus + : INVOICE_PAYMENT_STATUS.PENDING; + + // Buscar data do pagamento se a fatura estiver paga + let paymentDate: Date | null = null; + if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) { + const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod); + const paymentLancamento = await db.query.lancamentos.findFirst({ + columns: { + purchaseDate: true, + }, + where: and( + eq(lancamentos.userId, userId), + eq(lancamentos.note, invoiceNote) + ), + }); + paymentDate = paymentLancamento?.purchaseDate + ? new Date(paymentLancamento.purchaseDate) + : null; + } + + return { totalAmount, invoiceStatus, paymentDate }; +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx new file mode 100644 index 0000000..6af1b25 --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx @@ -0,0 +1,41 @@ +import { + FilterSkeleton, + InvoiceSummaryCardSkeleton, + TransactionsTableSkeleton, +} from "@/components/skeletons"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de fatura de cartão + * Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos + */ +export default function FaturaLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ + {/* Invoice Summary Card */} +
+ +
+ + {/* Seção de lançamentos */} +
+
+ {/* Header */} +
+ + +
+ + {/* Filtros */} + + + {/* Tabela */} + +
+
+
+ ); +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx new file mode 100644 index 0000000..56fa0c5 --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx @@ -0,0 +1,199 @@ +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 MonthPicker from "@/components/month-picker/month-picker"; +import { Button } from "@/components/ui/button"; +import { lancamentos, type Conta } from "@/db/schema"; +import { db } from "@/lib/db"; +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 { RiPencilLine } from "@remixicon/react"; +import { and, desc } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { fetchCardData, fetchInvoiceData } from "./data"; + +type PageSearchParams = Promise; + +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] = await Promise.all([ + fetchLancamentoFilterSources(userId), + loadLogoOptions(), + fetchInvoiceData(userId, cartaoId, selectedPeriod), + ]); + const sluggedFilters = buildSluggedFilters(filterSources); + const slugMaps = buildSlugMaps(sluggedFilters); + + const filters = buildLancamentoWhere({ + userId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + cardId: card.id, + }); + + const lancamentoRows = await db.query.lancamentos.findMany({ + where: and(...filters), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + orderBy: desc(lancamentos.purchaseDate), + }); + + 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 ( +
+ + +
+ + + + } + /> + } + /> +
+ +
+ +
+
+ ); +} diff --git a/app/(dashboard)/cartoes/actions.ts b/app/(dashboard)/cartoes/actions.ts new file mode 100644 index 0000000..233a3c6 --- /dev/null +++ b/app/(dashboard)/cartoes/actions.ts @@ -0,0 +1,165 @@ +"use server"; + +import { cartoes, contas } from "@/db/schema"; +import { type ActionResult, handleActionError } from "@/lib/actions/helpers"; +import { revalidateForEntity } from "@/lib/actions/helpers"; +import { + dayOfMonthSchema, + noteSchema, + optionalDecimalSchema, + uuidSchema, +} from "@/lib/schemas/common"; +import { formatDecimalForDb } from "@/lib/utils/currency"; +import { normalizeFilePath } from "@/lib/utils/string"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +const cardBaseSchema = z.object({ + name: z + .string({ message: "Informe o nome do cartão." }) + .trim() + .min(1, "Informe o nome do cartão."), + brand: z + .string({ message: "Informe a bandeira." }) + .trim() + .min(1, "Informe a bandeira."), + status: z + .string({ message: "Informe o status do cartão." }) + .trim() + .min(1, "Informe o status do cartão."), + closingDay: dayOfMonthSchema, + dueDay: dayOfMonthSchema, + note: noteSchema, + limit: optionalDecimalSchema, + logo: z + .string({ message: "Selecione um logo." }) + .trim() + .min(1, "Selecione um logo."), + contaId: uuidSchema("Conta"), +}); + +const createCardSchema = cardBaseSchema; +const updateCardSchema = cardBaseSchema.extend({ + id: uuidSchema("Cartão"), +}); +const deleteCardSchema = z.object({ + id: uuidSchema("Cartão"), +}); + +type CardCreateInput = z.infer; +type CardUpdateInput = z.infer; +type CardDeleteInput = z.infer; + +async function assertAccountOwnership(userId: string, contaId: string) { + const account = await db.query.contas.findFirst({ + columns: { id: true }, + where: and(eq(contas.id, contaId), eq(contas.userId, userId)), + }); + + if (!account) { + throw new Error("Conta vinculada não encontrada."); + } +} + +export async function createCardAction( + input: CardCreateInput +): Promise { + try { + const user = await getUser(); + const data = createCardSchema.parse(input); + + await assertAccountOwnership(user.id, data.contaId); + + const logoFile = normalizeFilePath(data.logo); + + await db.insert(cartoes).values({ + name: data.name, + brand: data.brand, + status: data.status, + closingDay: data.closingDay, + dueDay: data.dueDay, + note: data.note ?? null, + limit: formatDecimalForDb(data.limit), + logo: logoFile, + contaId: data.contaId, + userId: user.id, + }); + + revalidateForEntity("cartoes"); + + return { success: true, message: "Cartão criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updateCardAction( + input: CardUpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateCardSchema.parse(input); + + await assertAccountOwnership(user.id, data.contaId); + + const logoFile = normalizeFilePath(data.logo); + + const [updated] = await db + .update(cartoes) + .set({ + name: data.name, + brand: data.brand, + status: data.status, + closingDay: data.closingDay, + dueDay: data.dueDay, + note: data.note ?? null, + limit: formatDecimalForDb(data.limit), + logo: logoFile, + contaId: data.contaId, + }) + .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) + .returning(); + + if (!updated) { + return { + success: false, + error: "Cartão não encontrado.", + }; + } + + revalidateForEntity("cartoes"); + + return { success: true, message: "Cartão atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteCardAction( + input: CardDeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteCardSchema.parse(input); + + const [deleted] = await db + .delete(cartoes) + .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) + .returning({ id: cartoes.id }); + + if (!deleted) { + return { + success: false, + error: "Cartão não encontrado.", + }; + } + + revalidateForEntity("cartoes"); + + return { success: true, message: "Cartão removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/cartoes/data.ts b/app/(dashboard)/cartoes/data.ts new file mode 100644 index 0000000..5cc4ce4 --- /dev/null +++ b/app/(dashboard)/cartoes/data.ts @@ -0,0 +1,110 @@ +import { cartoes, contas, lancamentos } from "@/db/schema"; +import { db } from "@/lib/db"; +import { loadLogoOptions } from "@/lib/logo/options"; +import { and, eq, isNull, or, sql } from "drizzle-orm"; + +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: eq(cartoes.userId, userId), + with: { + conta: { + columns: { + id: true, + name: true, + }, + }, + }, + }), + db.query.contas.findMany({ + orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)], + where: eq(contas.userId, userId), + columns: { + id: true, + name: true, + logo: true, + }, + }), + loadLogoOptions(), + db + .select({ + cartaoId: lancamentos.cartaoId, + total: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) + ) + ) + .groupBy(lancamentos.cartaoId), + ]); + + const usageMap = new Map(); + usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => { + if (!row.cartaoId) return; + usageMap.set(row.cartaoId, Number(row.total ?? 0)); + }); + + const cards = cardRows.map((card) => ({ + id: card.id, + name: card.name, + brand: card.brand, + status: card.status, + closingDay: card.closingDay, + dueDay: card.dueDay, + note: card.note, + logo: card.logo, + limit: card.limit ? Number(card.limit) : null, + limitInUse: (() => { + const total = usageMap.get(card.id) ?? 0; + return total < 0 ? Math.abs(total) : 0; + })(), + limitAvailable: (() => { + if (!card.limit) { + return null; + } + const total = usageMap.get(card.id) ?? 0; + const inUse = total < 0 ? Math.abs(total) : 0; + return Math.max(Number(card.limit) - inUse, 0); + })(), + contaId: card.contaId, + contaName: card.conta?.name ?? "Conta não encontrada", + })); + + const accounts = accountRows.map((account) => ({ + id: account.id, + name: account.name, + logo: account.logo, + })); + + return { cards, accounts, logoOptions }; +} diff --git a/app/(dashboard)/cartoes/layout.tsx b/app/(dashboard)/cartoes/layout.tsx new file mode 100644 index 0000000..afb9b02 --- /dev/null +++ b/app/(dashboard)/cartoes/layout.tsx @@ -0,0 +1,25 @@ +import PageDescription from "@/components/page-description"; +import { RiBankCardLine } from "@remixicon/react"; + +export const metadata = { + title: "Cartões | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Cartões" + subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites + e transações previstas. Use o seletor abaixo para navegar pelos meses e + visualizar as movimentações correspondentes." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/cartoes/loading.tsx b/app/(dashboard)/cartoes/loading.tsx new file mode 100644 index 0000000..bba11ef --- /dev/null +++ b/app/(dashboard)/cartoes/loading.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de cartões + */ +export default function CartoesLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de cartões */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + + +
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/cartoes/page.tsx b/app/(dashboard)/cartoes/page.tsx new file mode 100644 index 0000000..385570c --- /dev/null +++ b/app/(dashboard)/cartoes/page.tsx @@ -0,0 +1,14 @@ +import { CardsPage } from "@/components/cartoes/cards-page"; +import { getUserId } from "@/lib/auth/server"; +import { fetchCardsForUser } from "./data"; + +export default async function Page() { + const userId = await getUserId(); + const { cards, accounts, logoOptions } = await fetchCardsForUser(userId); + + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/categorias/[categoryId]/page.tsx b/app/(dashboard)/categorias/[categoryId]/page.tsx new file mode 100644 index 0000000..d99e722 --- /dev/null +++ b/app/(dashboard)/categorias/[categoryId]/page.tsx @@ -0,0 +1,115 @@ +import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; +import { CategoryDetailHeader } from "@/components/categorias/category-detail-header"; +import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; +import MonthPicker from "@/components/month-picker/month-picker"; +import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details"; +import { getUserId } from "@/lib/auth/server"; +import { + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, +} from "@/lib/lancamentos/page-helpers"; +import { parsePeriodParam } from "@/lib/utils/period"; +import { notFound } from "next/navigation"; + +type PageSearchParams = Promise>; + +type PageProps = { + params: Promise<{ categoryId: string }>; + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? value[0] ?? null : value; +}; + +const formatPeriodLabel = (period: string) => { + const [yearStr, monthStr] = period.split("-"); + const year = Number.parseInt(yearStr ?? "", 10); + const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; + + if (Number.isNaN(year) || Number.isNaN(monthIndex) || monthIndex < 0) { + return period; + } + + const date = new Date(year, monthIndex, 1); + const label = date.toLocaleDateString("pt-BR", { + month: "long", + year: "numeric", + }); + + return label.charAt(0).toUpperCase() + label.slice(1); +}; + +export default async function Page({ params, searchParams }: PageProps) { + const { categoryId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); + + const [detail, filterSources, estabelecimentos] = await Promise.all([ + fetchCategoryDetails(userId, categoryId, selectedPeriod), + fetchLancamentoFilterSources(userId), + getRecentEstablishmentsAction(), + ]); + + if (!detail) { + notFound(); + } + + const sluggedFilters = buildSluggedFilters(filterSources); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); + + const currentPeriodLabel = formatPeriodLabel(detail.period); + const previousPeriodLabel = formatPeriodLabel(detail.previousPeriod); + + return ( +
+ + + +
+ ); +} diff --git a/app/(dashboard)/categorias/actions.ts b/app/(dashboard)/categorias/actions.ts new file mode 100644 index 0000000..39b33ef --- /dev/null +++ b/app/(dashboard)/categorias/actions.ts @@ -0,0 +1,176 @@ +"use server"; + +import { categorias } from "@/db/schema"; +import { + type ActionResult, + handleActionError, + revalidateForEntity, +} from "@/lib/actions/helpers"; +import { getUser } from "@/lib/auth/server"; +import { CATEGORY_TYPES } from "@/lib/categorias/constants"; +import { db } from "@/lib/db"; +import { uuidSchema } from "@/lib/schemas/common"; +import { normalizeIconInput } from "@/lib/utils/string"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +const categoryBaseSchema = z.object({ + name: z + .string({ message: "Informe o nome da categoria." }) + .trim() + .min(1, "Informe o nome da categoria."), + type: z.enum(CATEGORY_TYPES, { + message: "Tipo de categoria inválido.", + }), + icon: z + .string() + .trim() + .max(100, "O ícone deve ter no máximo 100 caracteres.") + .nullish() + .transform((value) => normalizeIconInput(value)), +}); + +const createCategorySchema = categoryBaseSchema; +const updateCategorySchema = categoryBaseSchema.extend({ + id: uuidSchema("Categoria"), +}); +const deleteCategorySchema = z.object({ + id: uuidSchema("Categoria"), +}); + +type CategoryCreateInput = z.infer; +type CategoryUpdateInput = z.infer; +type CategoryDeleteInput = z.infer; + +export async function createCategoryAction( + input: CategoryCreateInput +): Promise { + try { + const user = await getUser(); + const data = createCategorySchema.parse(input); + + await db.insert(categorias).values({ + name: data.name, + type: data.type, + icon: data.icon, + userId: user.id, + }); + + revalidateForEntity("categorias"); + + return { success: true, message: "Categoria criada com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updateCategoryAction( + input: CategoryUpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateCategorySchema.parse(input); + + // Buscar categoria antes de atualizar para verificar restrições + const categoria = await db.query.categorias.findFirst({ + columns: { id: true, name: true }, + where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)), + }); + + if (!categoria) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } + + // Bloquear edição das categorias protegidas + const categoriasProtegidas = [ + "Transferência interna", + "Saldo inicial", + "Pagamentos", + ]; + if (categoriasProtegidas.includes(categoria.name)) { + return { + success: false, + error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`, + }; + } + + const [updated] = await db + .update(categorias) + .set({ + name: data.name, + type: data.type, + icon: data.icon, + }) + .where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id))) + .returning(); + + if (!updated) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } + + revalidateForEntity("categorias"); + + return { success: true, message: "Categoria atualizada com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteCategoryAction( + input: CategoryDeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteCategorySchema.parse(input); + + // Buscar categoria antes de deletar para verificar restrições + const categoria = await db.query.categorias.findFirst({ + columns: { id: true, name: true }, + where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)), + }); + + if (!categoria) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } + + // Bloquear remoção das categorias protegidas + const categoriasProtegidas = [ + "Transferência interna", + "Saldo inicial", + "Pagamentos", + ]; + if (categoriasProtegidas.includes(categoria.name)) { + return { + success: false, + error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`, + }; + } + + const [deleted] = await db + .delete(categorias) + .where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id))) + .returning({ id: categorias.id }); + + if (!deleted) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } + + revalidateForEntity("categorias"); + + return { success: true, message: "Categoria removida com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/categorias/data.ts b/app/(dashboard)/categorias/data.ts new file mode 100644 index 0000000..5cf8930 --- /dev/null +++ b/app/(dashboard)/categorias/data.ts @@ -0,0 +1,26 @@ +import type { CategoryType } from "@/components/categorias/types"; +import { categorias, type Categoria } from "@/db/schema"; +import { db } from "@/lib/db"; +import { eq } from "drizzle-orm"; + +export type CategoryData = { + id: string; + name: string; + type: CategoryType; + icon: string | null; +}; + +export async function fetchCategoriesForUser( + userId: string +): Promise { + 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, + })); +} diff --git a/app/(dashboard)/categorias/layout.tsx b/app/(dashboard)/categorias/layout.tsx new file mode 100644 index 0000000..3e5e8ea --- /dev/null +++ b/app/(dashboard)/categorias/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiPriceTag3Line } from "@remixicon/react"; + +export const metadata = { + title: "Categorias | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Categorias" + subtitle="Gerencie suas categorias de despesas e receitas. Acompanhe o desempenho financeiro por categoria e faça ajustes conforme necessário." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/categorias/loading.tsx b/app/(dashboard)/categorias/loading.tsx new file mode 100644 index 0000000..e3bba35 --- /dev/null +++ b/app/(dashboard)/categorias/loading.tsx @@ -0,0 +1,61 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de categorias + * Layout: Header + Tabs + Grid de cards + */ +export default function CategoriasLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Tabs */} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + {/* Grid de cards de categorias */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ {/* Ícone + Nome */} +
+ +
+ + +
+
+ + {/* Descrição */} + {i % 3 === 0 && ( + + )} + + {/* Botões de ação */} +
+ + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/(dashboard)/categorias/page.tsx b/app/(dashboard)/categorias/page.tsx new file mode 100644 index 0000000..4f7b060 --- /dev/null +++ b/app/(dashboard)/categorias/page.tsx @@ -0,0 +1,14 @@ +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 ( +
+ +
+ ); +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/data.ts b/app/(dashboard)/contas/[contaId]/extrato/data.ts new file mode 100644 index 0000000..9967408 --- /dev/null +++ b/app/(dashboard)/contas/[contaId]/extrato/data.ts @@ -0,0 +1,131 @@ +import { contas, lancamentos, pagadores } from "@/db/schema"; +import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; +import { db } from "@/lib/db"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { and, eq, lt, sql } from "drizzle-orm"; + +export type AccountSummaryData = { + openingBalance: number; + currentBalance: number; + totalIncomes: number; + totalExpenses: number; +}; + +export async function fetchAccountData(userId: string, contaId: string) { + const account = await db.query.contas.findFirst({ + columns: { + id: true, + name: true, + accountType: true, + status: true, + initialBalance: true, + logo: true, + note: true, + }, + where: and(eq(contas.id, contaId), eq(contas.userId, userId)), + }); + + return account; +} + +export async function fetchAccountSummary( + userId: string, + contaId: string, + selectedPeriod: string +): Promise { + const [periodSummary] = await db + .select({ + netAmount: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + else ${lancamentos.amount} + end + ), + 0 + ) + `, + incomes: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount} + else 0 + end + ), + 0 + ) + `, + expenses: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount} + else 0 + end + ), + 0 + ) + `, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.contaId, contaId), + eq(lancamentos.period, selectedPeriod), + eq(lancamentos.isSettled, true), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ); + + const [previousRow] = await db + .select({ + previousMovements: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + else ${lancamentos.amount} + end + ), + 0 + ) + `, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.contaId, contaId), + lt(lancamentos.period, selectedPeriod), + eq(lancamentos.isSettled, true), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ); + + const account = await fetchAccountData(userId, contaId); + if (!account) { + throw new Error("Account not found"); + } + + const initialBalance = Number(account.initialBalance ?? 0); + const previousMovements = Number(previousRow?.previousMovements ?? 0); + const openingBalance = initialBalance + previousMovements; + const netAmount = Number(periodSummary?.netAmount ?? 0); + const totalIncomes = Number(periodSummary?.incomes ?? 0); + const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); + const currentBalance = openingBalance + netAmount; + + return { + openingBalance, + currentBalance, + totalIncomes, + totalExpenses, + }; +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/loading.tsx b/app/(dashboard)/contas/[contaId]/extrato/loading.tsx new file mode 100644 index 0000000..c0825f0 --- /dev/null +++ b/app/(dashboard)/contas/[contaId]/extrato/loading.tsx @@ -0,0 +1,38 @@ +import { + AccountStatementCardSkeleton, + FilterSkeleton, + TransactionsTableSkeleton, +} from "@/components/skeletons"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de extrato de conta + * Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos + */ +export default function ExtratoLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ + {/* Account Statement Card */} + + + {/* Seção de lançamentos */} +
+
+ {/* Header */} +
+ +
+ + {/* Filtros */} + + + {/* Tabela */} + +
+
+
+ ); +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx new file mode 100644 index 0000000..fe013eb --- /dev/null +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -0,0 +1,173 @@ +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 MonthPicker from "@/components/month-picker/month-picker"; +import { Button } from "@/components/ui/button"; +import { lancamentos } from "@/db/schema"; +import { db } from "@/lib/db"; +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 { RiPencilLine } from "@remixicon/react"; +import { and, desc, eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { fetchAccountData, fetchAccountSummary } from "./data"; + +type PageSearchParams = Promise; + +type PageProps = { + params: Promise<{ contaId: string }>; + searchParams?: PageSearchParams; +}; + +const capitalize = (value: string) => + value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; + +export default async function Page({ params, searchParams }: PageProps) { + const { contaId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { + period: selectedPeriod, + monthName, + year, + } = parsePeriodParam(periodoParamRaw); + + const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); + + const account = await fetchAccountData(userId, contaId); + + if (!account) { + notFound(); + } + + const [filterSources, logoOptions, accountSummary] = await Promise.all([ + fetchLancamentoFilterSources(userId), + loadLogoOptions(), + fetchAccountSummary(userId, contaId, selectedPeriod), + ]); + const sluggedFilters = buildSluggedFilters(filterSources); + const slugMaps = buildSlugMaps(sluggedFilters); + + const filters = buildLancamentoWhere({ + userId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + accountId: account.id, + }); + + filters.push(eq(lancamentos.isSettled, true)); + + const lancamentoRows = await db.query.lancamentos.findMany({ + where: and(...filters), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + orderBy: desc(lancamentos.purchaseDate), + }); + + const lancamentosData = mapLancamentosData(lancamentoRows); + + const { openingBalance, currentBalance, totalIncomes, totalExpenses } = + accountSummary; + + const periodLabel = `${capitalize(monthName)} de ${year}`; + + const accountDialogData: Account = { + id: account.id, + name: account.name, + accountType: account.accountType, + status: account.status, + note: account.note, + logo: account.logo, + initialBalance: Number(account.initialBalance ?? 0), + balance: currentBalance, + }; + + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + limitContaId: account.id, + }); + + return ( +
+ + + + + + } + /> + } + /> + +
+ +
+
+ ); +} diff --git a/app/(dashboard)/contas/actions.ts b/app/(dashboard)/contas/actions.ts new file mode 100644 index 0000000..98df16e --- /dev/null +++ b/app/(dashboard)/contas/actions.ts @@ -0,0 +1,383 @@ +"use server"; + +import { categorias, contas, lancamentos, pagadores } from "@/db/schema"; +import { + INITIAL_BALANCE_CATEGORY_NAME, + INITIAL_BALANCE_CONDITION, + INITIAL_BALANCE_NOTE, + INITIAL_BALANCE_PAYMENT_METHOD, + INITIAL_BALANCE_TRANSACTION_TYPE, +} from "@/lib/accounts/constants"; +import { type ActionResult, handleActionError } from "@/lib/actions/helpers"; +import { revalidateForEntity } from "@/lib/actions/helpers"; +import { noteSchema, uuidSchema } from "@/lib/schemas/common"; +import { formatDecimalForDbRequired } from "@/lib/utils/currency"; +import { getTodayInfo } from "@/lib/utils/date"; +import { normalizeFilePath } from "@/lib/utils/string"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { + TRANSFER_CATEGORY_NAME, + TRANSFER_CONDITION, + TRANSFER_ESTABLISHMENT, + TRANSFER_PAYMENT_METHOD, +} from "@/lib/transferencias/constants"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +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"), +}); + +const createAccountSchema = accountBaseSchema; +const updateAccountSchema = accountBaseSchema.extend({ + id: uuidSchema("Conta"), +}); +const deleteAccountSchema = z.object({ + id: uuidSchema("Conta"), +}); + +type AccountCreateInput = z.infer; +type AccountUpdateInput = z.infer; +type AccountDeleteInput = z.infer; + +export async function createAccountAction( + input: AccountCreateInput +): Promise { + 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, + 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 { + 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, + }) + .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 { + 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; + +export async function transferBetweenAccountsAction( + input: TransferInput +): Promise { + 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." + ); + } + + // Create outgoing transaction (transfer from source account) + await tx.insert(lancamentos).values({ + condition: TRANSFER_CONDITION, + name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`, + paymentMethod: TRANSFER_PAYMENT_METHOD, + note: `Transferência para ${toAccount.name}`, + 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} ← ${fromAccount.name}`, + paymentMethod: TRANSFER_PAYMENT_METHOD, + note: `Transferência de ${fromAccount.name}`, + 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); + } +} diff --git a/app/(dashboard)/contas/data.ts b/app/(dashboard)/contas/data.ts new file mode 100644 index 0000000..a5e1cc7 --- /dev/null +++ b/app/(dashboard)/contas/data.ts @@ -0,0 +1,95 @@ +import { contas, lancamentos, pagadores } from "@/db/schema"; +import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; +import { db } from "@/lib/db"; +import { loadLogoOptions } from "@/lib/logo/options"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { and, eq, sql } from "drizzle-orm"; + +export type AccountData = { + id: string; + name: string; + accountType: string; + status: string; + note: string | null; + logo: string | null; + initialBalance: number; + balance: number; + excludeFromBalance: boolean; +}; + +export async function fetchAccountsForUser( + userId: string, + currentPeriod: 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, + balanceMovements: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + else ${lancamentos.amount} + end + ), + 0 + ) + `, + }) + .from(contas) + .leftJoin( + lancamentos, + and( + eq(lancamentos.contaId, contas.id), + eq(lancamentos.userId, userId), + eq(lancamentos.period, currentPeriod), + eq(lancamentos.isSettled, true) + ) + ) + .leftJoin( + pagadores, + eq(lancamentos.pagadorId, pagadores.id) + ) + .where( + and( + eq(contas.userId, userId), + 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 + ), + 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, + })); + + return { accounts, logoOptions }; +} diff --git a/app/(dashboard)/contas/layout.tsx b/app/(dashboard)/contas/layout.tsx new file mode 100644 index 0000000..5436831 --- /dev/null +++ b/app/(dashboard)/contas/layout.tsx @@ -0,0 +1,25 @@ +import PageDescription from "@/components/page-description"; +import { RiBankLine } from "@remixicon/react"; + +export const metadata = { + title: "Contas | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Contas" + subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas, + despesas e transações previstas. Use o seletor abaixo para navegar pelos + meses e visualizar as movimentações correspondentes." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/contas/loading.tsx b/app/(dashboard)/contas/loading.tsx new file mode 100644 index 0000000..ad9573f --- /dev/null +++ b/app/(dashboard)/contas/loading.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de contas + */ +export default function ContasLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de contas */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/contas/page.tsx b/app/(dashboard)/contas/page.tsx new file mode 100644 index 0000000..3101225 --- /dev/null +++ b/app/(dashboard)/contas/page.tsx @@ -0,0 +1,22 @@ +import { AccountsPage } from "@/components/contas/accounts-page"; +import { getUserId } from "@/lib/auth/server"; +import { fetchAccountsForUser } from "./data"; + +export default async function Page() { + const userId = await getUserId(); + const now = new Date(); + const currentPeriod = `${now.getFullYear()}-${String( + now.getMonth() + 1 + ).padStart(2, "0")}`; + + const { accounts, logoOptions } = await fetchAccountsForUser( + userId, + currentPeriod + ); + + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx new file mode 100644 index 0000000..f725355 --- /dev/null +++ b/app/(dashboard)/dashboard/loading.tsx @@ -0,0 +1,17 @@ +import { DashboardGridSkeleton } from "@/components/skeletons"; + +/** + * Loading state para a página do dashboard + * Usa skeleton fiel ao layout final para evitar layout shift + */ +export default function DashboardLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ + {/* Dashboard content skeleton */} + +
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..7ee44bd --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,40 @@ +import { DashboardGrid } from "@/components/dashboard/dashboard-grid"; +import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome"; +import { SectionCards } from "@/components/dashboard/section-cards"; +import MonthPicker from "@/components/month-picker/month-picker"; +import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; +import { getUser } from "@/lib/auth/server"; +import { parsePeriodParam } from "@/lib/utils/period"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? value[0] ?? null : value; +}; + +export default async function Page({ searchParams }: PageProps) { + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); + + const data = await fetchDashboardData(user.id, selectedPeriod); + + return ( +
+ + + + +
+ ); +} diff --git a/app/(dashboard)/insights/actions.ts b/app/(dashboard)/insights/actions.ts new file mode 100644 index 0000000..8aa2362 --- /dev/null +++ b/app/(dashboard)/insights/actions.ts @@ -0,0 +1,817 @@ +"use server"; + +import { + cartoes, + categorias, + contas, + lancamentos, + orcamentos, + pagadores, + savedInsights, +} from "@/db/schema"; +import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { + InsightsResponseSchema, + type InsightsResponse, +} from "@/lib/schemas/insights"; +import { getPreviousPeriod } from "@/lib/utils/period"; +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 { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data"; + +const TRANSFERENCIA = "Transferência"; + +type ActionResult = + | { success: true; data: T } + | { success: false; error: string }; + +/** + * Função auxiliar para converter valores numéricos + */ +const toNumber = (value: unknown): number => { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + return 0; +}; + +/** + * Agrega dados financeiros do mês para análise + */ +async function aggregateMonthData(userId: string, period: string) { + const previousPeriod = getPreviousPeriod(period); + const twoMonthsAgo = getPreviousPeriod(previousPeriod); + const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo); + + // Buscar métricas de receitas e despesas dos últimos 3 meses + const [currentPeriodRows, previousPeriodRows, twoMonthsAgoRows, threeMonthsAgoRows] = await Promise.all([ + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, previousPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, twoMonthsAgo), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, threeMonthsAgo), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + ]); + + // Calcular totais dos últimos 3 meses + let currentIncome = 0; + let currentExpense = 0; + let previousIncome = 0; + let previousExpense = 0; + let twoMonthsAgoIncome = 0; + let twoMonthsAgoExpense = 0; + let threeMonthsAgoIncome = 0; + let threeMonthsAgoExpense = 0; + + for (const row of currentPeriodRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") currentIncome += amount; + else if (row.transactionType === "Despesa") currentExpense += amount; + } + + for (const row of previousPeriodRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") previousIncome += amount; + else if (row.transactionType === "Despesa") previousExpense += amount; + } + + for (const row of twoMonthsAgoRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") twoMonthsAgoIncome += amount; + else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount; + } + + for (const row of threeMonthsAgoRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") threeMonthsAgoIncome += amount; + else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount; + } + + // Buscar despesas por categoria (top 5) + const expensesByCategory = await db + .select({ + categoryName: categorias.name, + total: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(categorias.type, "despesa"), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(categorias.name) + .orderBy(sql`sum(${lancamentos.amount}) ASC`) + .limit(5); + + // Buscar orçamentos e uso + const budgetsData = await db + .select({ + categoryName: categorias.name, + budgetAmount: orcamentos.amount, + spent: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(orcamentos) + .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) + .leftJoin( + lancamentos, + and( + eq(lancamentos.categoriaId, categorias.id), + eq(lancamentos.period, period), + eq(lancamentos.userId, userId), + eq(lancamentos.transactionType, "Despesa") + ) + ) + .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) + .groupBy(categorias.name, orcamentos.amount); + + // Buscar métricas de cartões + const cardsData = await db + .select({ + totalLimit: sql`coalesce(sum(${cartoes.limit}), 0)`, + cardCount: sql`count(*)`, + }) + .from(cartoes) + .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))); + + // Buscar saldo total das contas + const accountsData = await db + .select({ + totalBalance: sql`coalesce(sum(${contas.initialBalance}), 0)`, + accountCount: sql`count(*)`, + }) + .from(contas) + .where( + and( + eq(contas.userId, userId), + eq(contas.status, "ativa"), + eq(contas.excludeFromBalance, false) + ) + ); + + // Calcular ticket médio das transações + const avgTicketData = await db + .select({ + avgAmount: sql`coalesce(avg(abs(${lancamentos.amount})), 0)`, + transactionCount: sql`count(*)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA) + ) + ); + + // Buscar gastos por dia da semana + const dayOfWeekSpending = await db + .select({ + purchaseDate: lancamentos.purchaseDate, + amount: lancamentos.amount, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ); + + // Agregar por dia da semana + const dayTotals = new Map(); + for (const row of dayOfWeekSpending) { + if (!row.purchaseDate) continue; + const dayOfWeek = getDay(new Date(row.purchaseDate)); + const current = dayTotals.get(dayOfWeek) ?? 0; + dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount))); + } + + // Buscar métodos de pagamento (agregado) + const paymentMethodsData = await db + .select({ + paymentMethod: lancamentos.paymentMethod, + total: sql`coalesce(sum(abs(${lancamentos.amount})), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ) + .groupBy(lancamentos.paymentMethod); + + // Buscar transações dos últimos 3 meses para análise de recorrência + const last3MonthsTransactions = await db + .select({ + name: lancamentos.name, + amount: lancamentos.amount, + period: lancamentos.period, + condition: lancamentos.condition, + installmentCount: lancamentos.installmentCount, + currentInstallment: lancamentos.currentInstallment, + categoryName: categorias.name, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where( + and( + eq(lancamentos.userId, userId), + sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`, + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA) + ) + ) + .orderBy(lancamentos.name); + + // Análise de recorrência + const transactionsByName = new Map>(); + + for (const tx of last3MonthsTransactions) { + const key = tx.name.toLowerCase().trim(); + if (!transactionsByName.has(key)) { + transactionsByName.set(key, []); + } + const transactions = transactionsByName.get(key); + if (transactions) { + transactions.push({ + period: tx.period, + amount: Math.abs(toNumber(tx.amount)), + }); + } + } + + // Identificar gastos recorrentes (aparece em 2+ meses com valor similar) + const recurringExpenses: Array<{ name: string; avgAmount: number; frequency: number }> = []; + let totalRecurring = 0; + + for (const [name, occurrences] of transactionsByName.entries()) { + if (occurrences.length >= 2) { + const amounts = occurrences.map(o => o.amount); + const avgAmount = amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length; + const maxDiff = Math.max(...amounts) - Math.min(...amounts); + + // Considerar recorrente se variação <= 20% da média + if (maxDiff <= avgAmount * 0.2) { + recurringExpenses.push({ + name, + avgAmount, + frequency: occurrences.length, + }); + + // Somar apenas os do mês atual + const currentMonthOccurrence = occurrences.find(o => o.period === period); + if (currentMonthOccurrence) { + totalRecurring += currentMonthOccurrence.amount; + } + } + } + } + + // Análise de gastos parcelados + const installmentTransactions = last3MonthsTransactions.filter( + tx => tx.condition === "parcelado" && tx.installmentCount && tx.installmentCount > 1 + ); + + const installmentData = installmentTransactions + .filter(tx => tx.period === period) + .map(tx => ({ + name: tx.name, + currentInstallment: tx.currentInstallment ?? 1, + totalInstallments: tx.installmentCount ?? 1, + amount: Math.abs(toNumber(tx.amount)), + category: tx.categoryName ?? "Outros", + })); + + const totalInstallmentAmount = installmentData.reduce((sum, tx) => sum + tx.amount, 0); + const futureCommitment = installmentData.reduce((sum, tx) => { + const remaining = (tx.totalInstallments - tx.currentInstallment); + return sum + (tx.amount * remaining); + }, 0); + + // Montar dados agregados e anonimizados + const aggregatedData = { + month: period, + totalIncome: currentIncome, + totalExpense: currentExpense, + balance: currentIncome - currentExpense, + + // Tendência de 3 meses + threeMonthTrend: { + periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period], + incomes: [threeMonthsAgoIncome, twoMonthsAgoIncome, previousIncome, currentIncome], + expenses: [threeMonthsAgoExpense, twoMonthsAgoExpense, previousExpense, currentExpense], + avgIncome: (threeMonthsAgoIncome + twoMonthsAgoIncome + previousIncome + currentIncome) / 4, + avgExpense: (threeMonthsAgoExpense + twoMonthsAgoExpense + previousExpense + currentExpense) / 4, + trend: currentExpense > previousExpense && previousExpense > twoMonthsAgoExpense + ? "crescente" + : currentExpense < previousExpense && previousExpense < twoMonthsAgoExpense + ? "decrescente" + : "estável", + }, + + previousMonthIncome: previousIncome, + previousMonthExpense: previousExpense, + monthOverMonthIncomeChange: + Math.abs(previousIncome) > 0.01 + ? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100 + : 0, + monthOverMonthExpenseChange: + Math.abs(previousExpense) > 0.01 + ? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100 + : 0, + savingsRate: + currentIncome > 0.01 + ? ((currentIncome - currentExpense) / currentIncome) * 100 + : 0, + topExpenseCategories: expensesByCategory.map( + (cat: { categoryName: string; total: unknown }) => ({ + category: cat.categoryName, + amount: Math.abs(toNumber(cat.total)), + percentageOfTotal: + currentExpense > 0 + ? (Math.abs(toNumber(cat.total)) / currentExpense) * 100 + : 0, + }) + ), + budgets: budgetsData.map( + (b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({ + category: b.categoryName, + budgetAmount: toNumber(b.budgetAmount), + spent: Math.abs(toNumber(b.spent)), + usagePercentage: + toNumber(b.budgetAmount) > 0 + ? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100 + : 0, + }) + ), + creditCards: { + totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0), + cardCount: toNumber(cardsData[0]?.cardCount ?? 0), + }, + accounts: { + totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0), + accountCount: toNumber(accountsData[0]?.accountCount ?? 0), + }, + avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0), + transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0), + dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({ + dayOfWeek: + ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A", + total, + })), + paymentMethodsBreakdown: paymentMethodsData.map( + (pm: { paymentMethod: string | null; total: unknown }) => ({ + method: pm.paymentMethod, + total: toNumber(pm.total), + percentage: + currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0, + }) + ), + + // Análise de recorrência + recurringExpenses: { + count: recurringExpenses.length, + total: totalRecurring, + percentageOfTotal: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, + topRecurring: recurringExpenses + .sort((a, b) => b.avgAmount - a.avgAmount) + .slice(0, 5) + .map(r => ({ + name: r.name, + avgAmount: r.avgAmount, + frequency: r.frequency, + })), + predictability: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, + }, + + // Análise de parcelamentos + installments: { + currentMonthInstallments: installmentData.length, + totalInstallmentAmount, + percentageOfExpenses: currentExpense > 0 ? (totalInstallmentAmount / currentExpense) * 100 : 0, + futureCommitment, + topInstallments: installmentData + .sort((a, b) => b.amount - a.amount) + .slice(0, 5) + .map(i => ({ + name: i.name, + current: i.currentInstallment, + total: i.totalInstallments, + amount: i.amount, + category: i.category, + remaining: i.totalInstallments - i.currentInstallment, + })), + }, + }; + + return aggregatedData; +} + +/** + * Gera insights usando IA + */ +export async function generateInsightsAction( + period: string, + modelId: string +): Promise> { + try { + const user = await getUser(); + + // Validar modelo - verificar se existe na lista ou se é um modelo customizado + const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId); + + // Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido + if (!selectedModel && !modelId.includes("/")) { + return { + success: false, + error: "Modelo inválido.", + }; + } + + // Agregar dados + const aggregatedData = await aggregateMonthData(user.id, period); + + // Selecionar provider + let model; + + // Se o modelo tem "/" é OpenRouter (formato: provider/model) + if (modelId.includes("/")) { + 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: + error instanceof Error + ? error.message + : "Erro ao gerar insights. Tente novamente.", + }; + } +} + +/** + * Salva insights gerados no banco de dados + */ +export async function saveInsightsAction( + period: string, + modelId: string, + data: InsightsResponse +): Promise> { + try { + const user = await getUser(); + + // Verificar se já existe um insight salvo para este período + const existing = await db + .select() + .from(savedInsights) + .where( + and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) + ) + .limit(1); + + if (existing.length > 0) { + // Atualizar existente + const updated = await db + .update(savedInsights) + .set({ + modelId, + data: JSON.stringify(data), + updatedAt: new Date(), + }) + .where( + and( + eq(savedInsights.userId, user.id), + eq(savedInsights.period, period) + ) + ) + .returning({ id: savedInsights.id, createdAt: savedInsights.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(savedInsights) + .values({ + userId: user.id, + period, + modelId, + data: JSON.stringify(data), + }) + .returning({ id: savedInsights.id, createdAt: savedInsights.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: + error instanceof Error + ? error.message + : "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(savedInsights) + .where( + and(eq(savedInsights.userId, user.id), eq(savedInsights.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: + error instanceof Error + ? error.message + : "Erro ao carregar análise salva. Tente novamente.", + }; + } +} + +/** + * Remove insights salvos do banco de dados + */ +export async function deleteSavedInsightsAction( + period: string +): Promise> { + try { + const user = await getUser(); + + await db + .delete(savedInsights) + .where( + and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) + ); + + return { + success: true, + data: undefined, + }; + } catch (error) { + console.error("Error deleting saved insights:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Erro ao remover análise. Tente novamente.", + }; + } +} diff --git a/app/(dashboard)/insights/data.ts b/app/(dashboard)/insights/data.ts new file mode 100644 index 0000000..506eed8 --- /dev/null +++ b/app/(dashboard)/insights/data.ts @@ -0,0 +1,145 @@ +/** + * Tipos de providers disponíveis + */ +export type AIProvider = "openai" | "anthropic" | "google" | "openrouter"; + +/** + * Metadados dos providers + */ +export const PROVIDERS = { + openai: { + id: "openai" as const, + name: "ChatGPT", + icon: "RiOpenaiLine", + }, + anthropic: { + id: "anthropic" as const, + name: "Claude AI", + icon: "RiRobot2Line", + }, + google: { + id: "google" as const, + name: "Gemini", + icon: "RiGoogleLine", + }, + openrouter: { + id: "openrouter" as const, + name: "OpenRouter", + icon: "RiRouterLine", + }, +} as const; + +/** + * Lista de modelos de IA disponíveis para análise de insights + */ +export const AVAILABLE_MODELS = [ + // OpenAI Models (5) + { id: "gpt-5", name: "GPT-5", provider: "openai" as const }, + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" as const }, + { id: "gpt-5-nano", name: "GPT-5 Nano", provider: "openai" as const }, + { id: "gpt-4.1", name: "GPT-4.1", provider: "openai" as const }, + { id: "gpt-4o", name: "GPT-4o (Omni)", provider: "openai" as const }, + + // Anthropic Models (5) + { + id: "claude-3.7-sonnet", + name: "Claude 3.7 Sonnet", + provider: "anthropic" as const, + }, + { + id: "claude-4-opus", + name: "Claude 4 Opus", + provider: "anthropic" as const, + }, + { + id: "claude-4.5-sonnet", + name: "Claude 4.5 Sonnet", + provider: "anthropic" as const, + }, + { + id: "claude-4.5-haiku", + name: "Claude 4.5 Haiku", + provider: "anthropic" as const, + }, + { + id: "claude-3.5-sonnet-20240620", + name: "Claude 3.5 Sonnet (2024-06-20)", + provider: "anthropic" as const, + }, + + // Google Models (5) + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + provider: "google" as const, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + provider: "google" as const, + }, +] as const; + +/** + * Modelo padrão + */ +export const DEFAULT_MODEL = "gpt-5"; +export const DEFAULT_PROVIDER = "openai"; + +/** + * System prompt para análise de insights + */ +export const INSIGHTS_SYSTEM_PROMPT = `Você é um especialista em comportamento financeiro. Analise os dados financeiros fornecidos e organize suas observações em 4 categorias específicas: + +1. **Comportamentos Observados** (behaviors): Padrões de gastos e hábitos financeiros identificados nos dados. Foque em comportamentos recorrentes e tendências. Considere: + - Tendência dos últimos 3 meses (crescente, decrescente, estável) + - Gastos recorrentes e sua previsibilidade + - Padrões de parcelamento e comprometimento futuro + +2. **Gatilhos de Consumo** (triggers): Identifique situações, períodos ou categorias que desencadeiam maiores gastos. O que leva o usuário a gastar mais? Analise: + - Dias da semana com mais gastos + - Categorias que cresceram nos últimos meses + - Métodos de pagamento que facilitam gastos + +3. **Recomendações Práticas** (recommendations): Sugestões concretas e acionáveis para melhorar a saúde financeira. Seja específico e direto. Use os dados de: + - Gastos recorrentes que podem ser otimizados + - Orçamentos que estão sendo ultrapassados + - Comprometimento futuro com parcelamentos + +4. **Melhorias Sugeridas** (improvements): Oportunidades de otimização e estratégias de longo prazo para alcançar objetivos financeiros. Considere: + - Tendências preocupantes dos últimos 3 meses + - Percentual de gastos recorrentes vs pontuais + - Estratégias para reduzir comprometimento futuro + +Para cada categoria, forneça de 3 a 6 itens concisos e objetivos. Use linguagem clara e direta, com verbos de ação. Mantenha privacidade e não exponha dados pessoais sensíveis. + +IMPORTANTE: Utilize os novos dados disponíveis (threeMonthTrend, recurringExpenses, installments) para fornecer insights mais ricos e contextualizados. + +Responda EXCLUSIVAMENTE com um JSON válido seguindo o esquema: +{ + "month": "YYYY-MM", + "generatedAt": "ISO datetime", + "categories": [ + { + "category": "behaviors", + "items": [ + { "text": "Observação aqui" }, + ... + ] + }, + { + "category": "triggers", + "items": [...] + }, + { + "category": "recommendations", + "items": [...] + }, + { + "category": "improvements", + "items": [...] + } + ] +} + +`; diff --git a/app/(dashboard)/insights/layout.tsx b/app/(dashboard)/insights/layout.tsx new file mode 100644 index 0000000..2179bb0 --- /dev/null +++ b/app/(dashboard)/insights/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiSparklingLine } from "@remixicon/react"; + +export const metadata = { + title: "Insights | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Insights" + subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/insights/loading.tsx b/app/(dashboard)/insights/loading.tsx new file mode 100644 index 0000000..52aad2c --- /dev/null +++ b/app/(dashboard)/insights/loading.tsx @@ -0,0 +1,42 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de insights com IA + */ +export default function InsightsLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de insights */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ + + +
+ +
+
+ + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/insights/page.tsx b/app/(dashboard)/insights/page.tsx new file mode 100644 index 0000000..c8ad780 --- /dev/null +++ b/app/(dashboard)/insights/page.tsx @@ -0,0 +1,31 @@ +import { InsightsPage } from "@/components/insights/insights-page"; +import MonthPicker from "@/components/month-picker/month-picker"; +import { parsePeriodParam } from "@/lib/utils/period"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? value[0] ?? null : value; +}; + +export default async function Page({ searchParams }: PageProps) { + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); + + return ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts new file mode 100644 index 0000000..41d059e --- /dev/null +++ b/app/(dashboard)/lancamentos/actions.ts @@ -0,0 +1,1403 @@ +"use server"; + +import { contas, lancamentos } from "@/db/schema"; +import { + INITIAL_BALANCE_CONDITION, + INITIAL_BALANCE_NOTE, + INITIAL_BALANCE_PAYMENT_METHOD, + INITIAL_BALANCE_TRANSACTION_TYPE, +} from "@/lib/accounts/constants"; +import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; +import type { ActionResult } from "@/lib/actions/types"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { + LANCAMENTO_CONDITIONS, + LANCAMENTO_PAYMENT_METHODS, + LANCAMENTO_TRANSACTION_TYPES, +} from "@/lib/lancamentos/constants"; +import { + buildEntriesByPagador, + sendPagadorAutoEmails, +} from "@/lib/pagadores/notifications"; +import { noteSchema, uuidSchema } from "@/lib/schemas/common"; +import { formatDecimalForDbRequired } from "@/lib/utils/currency"; +import { getTodayDateString } from "@/lib/utils/date"; +import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; + +const resolvePeriod = (purchaseDate: string, period?: string | null) => { + if (period && /^\d{4}-\d{2}$/.test(period)) { + return period; + } + + const date = new Date(purchaseDate); + if (Number.isNaN(date.getTime())) { + throw new Error("Data da transação inválida."); + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; +}; + +const getTodayDate = () => new Date(getTodayDateString()); + +const baseFields = z.object({ + purchaseDate: z + .string({ message: "Informe a data da transação." }) + .trim() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: "Data da transação inválida.", + }), + period: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }) + .optional(), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + transactionType: z + .enum(LANCAMENTO_TRANSACTION_TYPES, { + message: "Selecione um tipo de transação válido.", + }) + .default(LANCAMENTO_TRANSACTION_TYPES[0]), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero."), + condition: z.enum(LANCAMENTO_CONDITIONS, { + message: "Selecione uma condição válida.", + }), + paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS, { + message: "Selecione uma forma de pagamento válida.", + }), + pagadorId: uuidSchema("Pagador").nullable().optional(), + secondaryPagadorId: uuidSchema("Pagador secundário").optional(), + isSplit: z.boolean().optional().default(false), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + categoriaId: uuidSchema("Categoria").nullable().optional(), + note: noteSchema, + installmentCount: z.coerce + .number() + .int() + .min(1, "Selecione uma quantidade válida.") + .max(60, "Selecione uma quantidade válida.") + .optional(), + recurrenceCount: z.coerce + .number() + .int() + .min(1, "Selecione uma recorrência válida.") + .max(60, "Selecione uma recorrência válida.") + .optional(), + dueDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de vencimento válida.", + }) + .optional(), + boletoPaymentDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de pagamento válida.", + }) + .optional(), + isSettled: z.boolean().nullable().optional(), +}); + +const refineLancamento = ( + data: z.infer & { id?: string }, + ctx: z.RefinementCtx +) => { + if (data.condition === "Parcelado") { + if (!data.installmentCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["installmentCount"], + message: "Informe a quantidade de parcelas.", + }); + } else if (data.installmentCount < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["installmentCount"], + message: "Selecione pelo menos duas parcelas.", + }); + } + } + + if (data.condition === "Recorrente") { + if (!data.recurrenceCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["recurrenceCount"], + message: "Informe por quantos meses a recorrência acontecerá.", + }); + } else if (data.recurrenceCount < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["recurrenceCount"], + message: "A recorrência deve ter ao menos dois meses.", + }); + } + } + + if (data.isSplit) { + if (!data.pagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["pagadorId"], + message: "Selecione o pagador principal para dividir o lançamento.", + }); + } + + if (!data.secondaryPagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["secondaryPagadorId"], + message: "Selecione o pagador secundário para dividir o lançamento.", + }); + } else if (data.pagadorId && data.secondaryPagadorId === data.pagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["secondaryPagadorId"], + message: "Escolha um pagador diferente para dividir o lançamento.", + }); + } + } +}; + +const createSchema = baseFields.superRefine(refineLancamento); +const updateSchema = baseFields + .extend({ + id: uuidSchema("Lançamento"), + }) + .superRefine(refineLancamento); + +const deleteSchema = z.object({ + id: uuidSchema("Lançamento"), +}); + +const toggleSettlementSchema = z.object({ + id: uuidSchema("Lançamento"), + value: z.boolean({ + message: "Informe o status de pagamento.", + }), +}); + +type BaseInput = z.infer; +type CreateInput = z.infer; +type UpdateInput = z.infer; +type DeleteInput = z.infer; +type ToggleSettlementInput = z.infer; + +const revalidate = () => revalidateForEntity("lancamentos"); + +const resolveUserLabel = (user: { + name?: string | null; + email?: string | null; +}) => { + if (user?.name && user.name.trim().length > 0) { + return user.name; + } + if (user?.email && user.email.trim().length > 0) { + return user.email; + } + return "OpenSheets"; +}; + +type InitialCandidate = { + note: string | null; + transactionType: string | null; + condition: string | null; + paymentMethod: string | null; +}; + +const isInitialBalanceLancamento = (record?: InitialCandidate | null) => + !!record && + record.note === INITIAL_BALANCE_NOTE && + record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE && + record.condition === INITIAL_BALANCE_CONDITION && + record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD; + +const centsToDecimalString = (value: number) => { + const decimal = value / 100; + const formatted = decimal.toFixed(2); + return Object.is(decimal, -0) ? "0.00" : formatted; +}; + +const splitAmount = (totalCents: number, parts: number) => { + if (parts <= 0) { + return []; + } + + const base = Math.trunc(totalCents / parts); + const remainder = totalCents % parts; + + return Array.from( + { length: parts }, + (_, index) => base + (index < remainder ? 1 : 0) + ); +}; + +const addMonthsToPeriod = (period: string, offset: number) => { + const [yearStr, monthStr] = period.split("-"); + const baseYear = Number(yearStr); + const baseMonth = Number(monthStr); + + if (!baseYear || !baseMonth) { + throw new Error("Período inválido."); + } + + const date = new Date(baseYear, baseMonth - 1, 1); + date.setMonth(date.getMonth() + offset); + + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, "0"); + return `${nextYear}-${nextMonth}`; +}; + +const addMonthsToDate = (value: Date, offset: number) => { + const result = new Date(value); + const originalDay = result.getDate(); + + result.setDate(1); + result.setMonth(result.getMonth() + offset); + + const lastDay = new Date( + result.getFullYear(), + result.getMonth() + 1, + 0 + ).getDate(); + + result.setDate(Math.min(originalDay, lastDay)); + return result; +}; + +type Share = { + pagadorId: string | null; + amountCents: number; +}; + +const buildShares = ({ + totalCents, + pagadorId, + isSplit, + secondaryPagadorId, +}: { + totalCents: number; + pagadorId: string | null; + isSplit: boolean; + secondaryPagadorId?: string; +}): Share[] => { + if (isSplit) { + if (!pagadorId || !secondaryPagadorId) { + throw new Error("Configuração de divisão inválida para o lançamento."); + } + + const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); + return [ + { pagadorId, amountCents: primaryAmount }, + { pagadorId: secondaryPagadorId, amountCents: secondaryAmount }, + ]; + } + + return [{ pagadorId, amountCents: totalCents }]; +}; + +type BuildLancamentoRecordsParams = { + data: BaseInput; + userId: string; + period: string; + purchaseDate: Date; + dueDate: Date | null; + boletoPaymentDate: Date | null; + shares: Share[]; + amountSign: 1 | -1; + shouldNullifySettled: boolean; + seriesId: string | null; +}; + +type LancamentoInsert = typeof lancamentos.$inferInsert; + +const buildLancamentoRecords = ({ + data, + userId, + period, + purchaseDate, + dueDate, + boletoPaymentDate, + shares, + amountSign, + shouldNullifySettled, + seriesId, +}: BuildLancamentoRecordsParams): LancamentoInsert[] => { + const records: LancamentoInsert[] = []; + + const basePayload = { + name: data.name, + transactionType: data.transactionType, + condition: data.condition, + paymentMethod: data.paymentMethod, + note: data.note ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + categoriaId: data.categoriaId ?? null, + recurrenceCount: null as number | null, + installmentCount: null as number | null, + currentInstallment: null as number | null, + isDivided: data.isSplit ?? false, + userId, + seriesId, + }; + + const resolveSettledValue = (cycleIndex: number) => { + if (shouldNullifySettled) { + return null; + } + const initialSettled = data.isSettled ?? false; + if (data.condition === "Parcelado" || data.condition === "Recorrente") { + return cycleIndex === 0 ? initialSettled : false; + } + return initialSettled; + }; + + if (data.condition === "Parcelado") { + const installmentTotal = data.installmentCount ?? 0; + const amountsByShare = shares.map((share) => + splitAmount(share.amountCents, installmentTotal) + ); + + for ( + let installment = 0; + installment < installmentTotal; + installment += 1 + ) { + const installmentPeriod = addMonthsToPeriod(period, installment); + const installmentPurchaseDate = addMonthsToDate( + purchaseDate, + installment + ); + const installmentDueDate = dueDate + ? addMonthsToDate(dueDate, installment) + : null; + + shares.forEach((share, shareIndex) => { + const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; + const settled = resolveSettledValue(installment); + records.push({ + ...basePayload, + amount: centsToDecimalString(amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate: installmentPurchaseDate, + period: installmentPeriod, + isSettled: settled, + installmentCount: installmentTotal, + currentInstallment: installment + 1, + recurrenceCount: null, + dueDate: installmentDueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled + ? boletoPaymentDate + : null, + }); + }); + } + + return records; + } + + if (data.condition === "Recorrente") { + const recurrenceTotal = data.recurrenceCount ?? 0; + + for (let index = 0; index < recurrenceTotal; index += 1) { + const recurrencePeriod = addMonthsToPeriod(period, index); + const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index); + const recurrenceDueDate = dueDate + ? addMonthsToDate(dueDate, index) + : null; + + shares.forEach((share) => { + const settled = resolveSettledValue(index); + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate: recurrencePurchaseDate, + period: recurrencePeriod, + isSettled: settled, + recurrenceCount: recurrenceTotal, + dueDate: recurrenceDueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled + ? boletoPaymentDate + : null, + }); + }); + } + + return records; + } + + shares.forEach((share) => { + const settled = resolveSettledValue(0); + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate, + period, + isSettled: settled, + dueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, + }); + }); + + return records; +}; + +export async function createLancamentoAction( + input: CreateInput +): Promise { + try { + const user = await getUser(); + const data = createSchema.parse(input); + + const period = resolvePeriod(data.purchaseDate, data.period); + const purchaseDate = new Date(data.purchaseDate); + const dueDate = data.dueDate ? new Date(data.dueDate) : null; + const shouldSetBoletoPaymentDate = + data.paymentMethod === "Boleto" && (data.isSettled ?? false); + const boletoPaymentDate = shouldSetBoletoPaymentDate + ? data.boletoPaymentDate + ? new Date(data.boletoPaymentDate) + : getTodayDate() + : null; + + const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(data.amount) * 100); + const shouldNullifySettled = data.paymentMethod === "Cartão de crédito"; + + const shares = buildShares({ + totalCents, + pagadorId: data.pagadorId ?? null, + isSplit: data.isSplit ?? false, + secondaryPagadorId: data.secondaryPagadorId, + }); + + const isSeriesLancamento = + data.condition === "Parcelado" || data.condition === "Recorrente"; + const seriesId = isSeriesLancamento ? randomUUID() : null; + + const records = buildLancamentoRecords({ + data, + userId: user.id, + period, + purchaseDate, + dueDate, + shares, + amountSign, + shouldNullifySettled, + boletoPaymentDate, + seriesId, + }); + + if (!records.length) { + throw new Error("Não foi possível criar os lançamentos solicitados."); + } + + await db.transaction(async (tx: typeof db) => { + await tx.insert(lancamentos).values(records); + }); + + const notificationEntries = buildEntriesByPagador( + records.map((record) => ({ + pagadorId: record.pagadorId ?? null, + name: record.name ?? null, + amount: record.amount ?? null, + transactionType: record.transactionType ?? null, + paymentMethod: record.paymentMethod ?? null, + condition: record.condition ?? null, + purchaseDate: record.purchaseDate ?? null, + period: record.period ?? null, + note: record.note ?? null, + })) + ); + + if (notificationEntries.size > 0) { + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "created", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + return { success: true, message: "Lançamento criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updateLancamentoAction( + input: UpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + note: true, + transactionType: true, + condition: true, + paymentMethod: true, + contaId: true, + categoriaId: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + with: { + categoria: { + columns: { + name: true, + }, + }, + }, + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + // Bloquear edição de lançamentos com categorias protegidas + // Nota: "Transferência interna" foi removida para permitir correção de valores + const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; + if ( + existing.categoria?.name && + categoriasProtegidasEdicao.includes(existing.categoria.name) + ) { + return { + success: false, + error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser editados.`, + }; + } + + const period = resolvePeriod(data.purchaseDate, data.period); + const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; + const amountCents = Math.round(Math.abs(data.amount) * 100); + const normalizedAmount = centsToDecimalString(amountCents * amountSign); + const normalizedSettled = + data.paymentMethod === "Cartão de crédito" + ? null + : data.isSettled ?? false; + const shouldSetBoletoPaymentDate = + data.paymentMethod === "Boleto" && Boolean(normalizedSettled); + const boletoPaymentDateValue = shouldSetBoletoPaymentDate + ? data.boletoPaymentDate + ? new Date(data.boletoPaymentDate) + : getTodayDate() + : null; + + await db + .update(lancamentos) + .set({ + name: data.name, + purchaseDate: new Date(data.purchaseDate), + transactionType: data.transactionType, + amount: normalizedAmount, + condition: data.condition, + paymentMethod: data.paymentMethod, + pagadorId: data.pagadorId ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + categoriaId: data.categoriaId ?? null, + note: data.note ?? null, + isSettled: normalizedSettled, + installmentCount: data.installmentCount ?? null, + recurrenceCount: data.recurrenceCount ?? null, + dueDate: data.dueDate ? new Date(data.dueDate) : null, + boletoPaymentDate: boletoPaymentDateValue, + period, + }) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + + if (isInitialBalanceLancamento(existing) && existing?.contaId) { + const updatedInitialBalance = formatDecimalForDbRequired( + Math.abs(data.amount ?? 0) + ); + await db + .update(contas) + .set({ initialBalance: updatedInitialBalance }) + .where( + and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)) + ); + } + + revalidate(); + + return { success: true, message: "Lançamento atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteLancamentoAction( + input: DeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + pagadorId: true, + amount: true, + transactionType: true, + paymentMethod: true, + condition: true, + purchaseDate: true, + period: true, + note: true, + categoriaId: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + with: { + categoria: { + columns: { + name: true, + }, + }, + }, + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + // Bloquear remoção de lançamentos com categorias protegidas + // Nota: "Transferência interna" foi removida para permitir correção/exclusão + const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; + if ( + existing.categoria?.name && + categoriasProtegidasRemocao.includes(existing.categoria.name) + ) { + return { + success: false, + error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser removidos.`, + }; + } + + await db + .delete(lancamentos) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + + if (existing.pagadorId) { + const notificationEntries = buildEntriesByPagador([ + { + pagadorId: existing.pagadorId, + name: existing.name ?? null, + amount: existing.amount ?? null, + transactionType: existing.transactionType ?? null, + paymentMethod: existing.paymentMethod ?? null, + condition: existing.condition ?? null, + purchaseDate: existing.purchaseDate ?? null, + period: existing.period ?? null, + note: existing.note ?? null, + }, + ]); + + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "deleted", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + return { success: true, message: "Lançamento removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function toggleLancamentoSettlementAction( + input: ToggleSettlementInput +): Promise { + try { + const user = await getUser(); + const data = toggleSettlementSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { id: true, paymentMethod: true }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (existing.paymentMethod === "Cartão de crédito") { + return { + success: false, + error: "Pagamentos com cartão são conciliados automaticamente.", + }; + } + + const isBoleto = existing.paymentMethod === "Boleto"; + const boletoPaymentDate = isBoleto + ? data.value + ? getTodayDate() + : null + : null; + + await db + .update(lancamentos) + .set({ + isSettled: data.value, + boletoPaymentDate, + }) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + + revalidate(); + + return { + success: true, + message: data.value + ? "Lançamento marcado como pago." + : "Pagamento desfeito com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } +} + +const deleteBulkSchema = z.object({ + id: uuidSchema("Lançamento"), + scope: z.enum(["current", "future", "all"], { + message: "Escopo de ação inválido.", + }), +}); + +type DeleteBulkInput = z.infer; + +export async function deleteLancamentoBulkAction( + input: DeleteBulkInput +): Promise { + try { + const user = await getUser(); + const data = deleteBulkSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + seriesId: true, + period: true, + condition: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (!existing.seriesId) { + return { + success: false, + error: "Este lançamento não faz parte de uma série.", + }; + } + + if (data.scope === "current") { + await db + .delete(lancamentos) + .where( + and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)) + ); + + revalidate(); + return { success: true, message: "Lançamento removido com sucesso." }; + } + + if (data.scope === "future") { + await db + .delete(lancamentos) + .where( + and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + sql`${lancamentos.period} >= ${existing.period}` + ) + ); + + revalidate(); + return { + success: true, + message: "Lançamentos removidos com sucesso.", + }; + } + + if (data.scope === "all") { + await db + .delete(lancamentos) + .where( + and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id) + ) + ); + + revalidate(); + return { + success: true, + message: "Todos os lançamentos da série foram removidos.", + }; + } + + return { success: false, error: "Escopo de ação inválido." }; + } catch (error) { + return handleActionError(error); + } +} + +const updateBulkSchema = z.object({ + id: uuidSchema("Lançamento"), + scope: z.enum(["current", "future", "all"], { + message: "Escopo de ação inválido.", + }), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + categoriaId: uuidSchema("Categoria").nullable().optional(), + note: noteSchema, + pagadorId: uuidSchema("Pagador").nullable().optional(), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero.") + .optional(), + dueDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de vencimento válida.", + }) + .optional() + .nullable(), + boletoPaymentDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de pagamento válida.", + }) + .optional() + .nullable(), +}); + +type UpdateBulkInput = z.infer; + +export async function updateLancamentoBulkAction( + input: UpdateBulkInput +): Promise { + try { + const user = await getUser(); + const data = updateBulkSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + seriesId: true, + period: true, + condition: true, + transactionType: true, + purchaseDate: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (!existing.seriesId) { + return { + success: false, + error: "Este lançamento não faz parte de uma série.", + }; + } + + const baseUpdatePayload: Record = { + name: data.name, + categoriaId: data.categoriaId ?? null, + note: data.note ?? null, + pagadorId: data.pagadorId ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + }; + + if (data.amount !== undefined) { + const amountSign: 1 | -1 = + existing.transactionType === "Despesa" ? -1 : 1; + const amountCents = Math.round(Math.abs(data.amount) * 100); + baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign); + } + + const hasDueDateUpdate = data.dueDate !== undefined; + const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; + + const baseDueDate = + hasDueDateUpdate && data.dueDate + ? new Date(data.dueDate) + : hasDueDateUpdate + ? null + : undefined; + + const baseBoletoPaymentDate = + hasBoletoPaymentDateUpdate && data.boletoPaymentDate + ? new Date(data.boletoPaymentDate) + : hasBoletoPaymentDateUpdate + ? null + : undefined; + + const basePurchaseDate = existing.purchaseDate ?? null; + + const buildDueDateForRecord = (recordPurchaseDate: Date | null) => { + if (!hasDueDateUpdate) { + return undefined; + } + + if (!baseDueDate) { + return null; + } + + if (!basePurchaseDate || !recordPurchaseDate) { + return baseDueDate; + } + + const monthDiff = + (recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * + 12 + + (recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); + + return addMonthsToDate(baseDueDate, monthDiff); + }; + + const applyUpdates = async ( + records: Array<{ id: string; purchaseDate: Date | null }> + ) => { + if (records.length === 0) { + return; + } + + await db.transaction(async (tx: typeof db) => { + for (const record of records) { + const perRecordPayload: Record = { + ...baseUpdatePayload, + }; + + const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); + if (dueDateForRecord !== undefined) { + perRecordPayload.dueDate = dueDateForRecord; + } + + if (hasBoletoPaymentDateUpdate) { + perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null; + } + + await tx + .update(lancamentos) + .set(perRecordPayload) + .where( + and( + eq(lancamentos.id, record.id), + eq(lancamentos.userId, user.id) + ) + ); + } + }); + }; + + if (data.scope === "current") { + await applyUpdates([ + { + id: data.id, + purchaseDate: existing.purchaseDate ?? null, + }, + ]); + + revalidate(); + return { success: true, message: "Lançamento atualizado com sucesso." }; + } + + if (data.scope === "future") { + const futureLancamentos = await db.query.lancamentos.findMany({ + columns: { + id: true, + purchaseDate: true, + }, + where: and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + sql`${lancamentos.period} >= ${existing.period}` + ), + orderBy: asc(lancamentos.purchaseDate), + }); + + await applyUpdates( + futureLancamentos.map((item) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })) + ); + + revalidate(); + return { + success: true, + message: "Lançamentos atualizados com sucesso.", + }; + } + + if (data.scope === "all") { + const allLancamentos = await db.query.lancamentos.findMany({ + columns: { + id: true, + purchaseDate: true, + }, + where: and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id) + ), + orderBy: asc(lancamentos.purchaseDate), + }); + + await applyUpdates( + allLancamentos.map((item) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })) + ); + + revalidate(); + return { + success: true, + message: "Todos os lançamentos da série foram atualizados.", + }; + } + + return { success: false, error: "Escopo de ação inválido." }; + } catch (error) { + return handleActionError(error); + } +} + +// Mass Add Schema +const massAddTransactionSchema = z.object({ + purchaseDate: z + .string({ message: "Informe a data da transação." }) + .trim() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: "Data da transação inválida.", + }), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero."), + categoriaId: uuidSchema("Categoria").nullable().optional(), +}); + +const massAddSchema = z.object({ + fixedFields: z.object({ + transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(), + pagadorId: uuidSchema("Pagador").nullable().optional(), + paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(), + condition: z.enum(LANCAMENTO_CONDITIONS).optional(), + period: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }) + .optional(), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + }), + transactions: z + .array(massAddTransactionSchema) + .min(1, "Adicione pelo menos uma transação."), +}); + +type MassAddInput = z.infer; + +export async function createMassLancamentosAction( + input: MassAddInput +): Promise { + try { + const user = await getUser(); + const data = massAddSchema.parse(input); + + // Default values for non-fixed fields + const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0]; + const defaultCondition = LANCAMENTO_CONDITIONS[0]; + const defaultPaymentMethod = LANCAMENTO_PAYMENT_METHODS[0]; + + const allRecords: LancamentoInsert[] = []; + const notificationData: Array<{ + pagadorId: string | null; + name: string | null; + amount: string | null; + transactionType: string | null; + paymentMethod: string | null; + condition: string | null; + purchaseDate: Date | null; + period: string | null; + note: string | null; + }> = []; + + // Process each transaction + for (const transaction of data.transactions) { + const transactionType = + data.fixedFields.transactionType ?? defaultTransactionType; + const condition = data.fixedFields.condition ?? defaultCondition; + const paymentMethod = + data.fixedFields.paymentMethod ?? defaultPaymentMethod; + const pagadorId = data.fixedFields.pagadorId ?? null; + const contaId = + paymentMethod === "Cartão de crédito" + ? null + : data.fixedFields.contaId ?? null; + const cartaoId = + paymentMethod === "Cartão de crédito" + ? data.fixedFields.cartaoId ?? null + : null; + const categoriaId = transaction.categoriaId ?? null; + + const period = + data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate); + const purchaseDate = new Date(transaction.purchaseDate); + const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(transaction.amount) * 100); + const amount = centsToDecimalString(totalCents * amountSign); + const isSettled = paymentMethod === "Cartão de crédito" ? null : false; + + const record: LancamentoInsert = { + name: transaction.name, + purchaseDate, + period, + transactionType, + amount, + condition, + paymentMethod, + pagadorId, + contaId, + cartaoId, + categoriaId, + note: null, + installmentCount: null, + recurrenceCount: null, + currentInstallment: null, + isSettled, + isDivided: false, + dueDate: null, + boletoPaymentDate: null, + userId: user.id, + seriesId: null, + }; + + allRecords.push(record); + + notificationData.push({ + pagadorId, + name: transaction.name, + amount, + transactionType, + paymentMethod, + condition, + purchaseDate, + period, + note: null, + }); + } + + if (!allRecords.length) { + throw new Error("Não foi possível criar os lançamentos solicitados."); + } + + // Insert all records in a single transaction + await db.transaction(async (tx: typeof db) => { + await tx.insert(lancamentos).values(allRecords); + }); + + // Send notifications + const notificationEntries = buildEntriesByPagador(notificationData); + + if (notificationEntries.size > 0) { + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "created", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + const count = allRecords.length; + return { + success: true, + message: `${count} ${ + count === 1 ? "lançamento criado" : "lançamentos criados" + } com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } +} + +// Delete multiple lancamentos at once +const deleteMultipleSchema = z.object({ + ids: z + .array(uuidSchema("Lançamento")) + .min(1, "Selecione pelo menos um lançamento."), +}); + +type DeleteMultipleInput = z.infer; + +export async function deleteMultipleLancamentosAction( + input: DeleteMultipleInput +): Promise { + try { + const user = await getUser(); + const data = deleteMultipleSchema.parse(input); + + // Fetch all lancamentos to be deleted + const existing = await db.query.lancamentos.findMany({ + columns: { + id: true, + name: true, + pagadorId: true, + amount: true, + transactionType: true, + paymentMethod: true, + condition: true, + purchaseDate: true, + period: true, + note: true, + }, + where: and( + inArray(lancamentos.id, data.ids), + eq(lancamentos.userId, user.id) + ), + }); + + if (existing.length === 0) { + return { success: false, error: "Nenhum lançamento encontrado." }; + } + + // Delete all lancamentos + await db + .delete(lancamentos) + .where( + and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)) + ); + + // Send notifications + const notificationData = existing + .filter((item): item is typeof item & { pagadorId: NonNullable } => + Boolean(item.pagadorId) + ) + .map((item) => ({ + pagadorId: item.pagadorId, + name: item.name ?? null, + amount: item.amount ?? null, + transactionType: item.transactionType ?? null, + paymentMethod: item.paymentMethod ?? null, + condition: item.condition ?? null, + purchaseDate: item.purchaseDate ?? null, + period: item.period ?? null, + note: item.note ?? null, + })); + + if (notificationData.length > 0) { + const notificationEntries = buildEntriesByPagador(notificationData); + + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "deleted", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + const count = existing.length; + return { + success: true, + message: `${count} ${ + count === 1 ? "lançamento removido" : "lançamentos removidos" + } com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } +} + +// Get unique establishment names from the last 3 months +export async function getRecentEstablishmentsAction(): Promise { + try { + const user = await getUser(); + + // Calculate date 3 months ago + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + // Fetch establishment names from the last 3 months + const results = await db + .select({ name: lancamentos.name }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + gte(lancamentos.purchaseDate, threeMonthsAgo) + ) + ) + .orderBy(desc(lancamentos.purchaseDate)); + + // Remove duplicates and filter empty names + const uniqueNames = Array.from( + new Set( + results + .map((r) => r.name) + .filter( + (name): name is string => + name != null && + name.trim().length > 0 && + !name.toLowerCase().startsWith("pagamento fatura") + ) + ) + ); + + // Return top 50 most recent unique establishments + return uniqueNames.slice(0, 100); + } catch (error) { + console.error("Error fetching recent establishments:", error); + return []; + } +} diff --git a/app/(dashboard)/lancamentos/anticipation-actions.ts b/app/(dashboard)/lancamentos/anticipation-actions.ts new file mode 100644 index 0000000..e9dc201 --- /dev/null +++ b/app/(dashboard)/lancamentos/anticipation-actions.ts @@ -0,0 +1,471 @@ +"use server"; + +import { + categorias, + installmentAnticipations, + lancamentos, + pagadores, + type InstallmentAnticipation, + type Lancamento, +} from "@/db/schema"; +import { handleActionError } from "@/lib/actions/helpers"; +import type { ActionResult } from "@/lib/actions/types"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { + generateAnticipationDescription, + generateAnticipationNote, +} from "@/lib/installments/anticipation-helpers"; +import type { + CancelAnticipationInput, + CreateAnticipationInput, + EligibleInstallment, + InstallmentAnticipationWithRelations, +} from "@/lib/installments/anticipation-types"; +import { uuidSchema } from "@/lib/schemas/common"; +import { formatDecimalForDbRequired } from "@/lib/utils/currency"; +import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +/** + * Schema de validação para criar antecipação + */ +const createAnticipationSchema = z.object({ + seriesId: uuidSchema("Série"), + installmentIds: z + .array(uuidSchema("Parcela")) + .min(1, "Selecione pelo menos uma parcela para antecipar."), + anticipationPeriod: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }), + discount: z.coerce + .number() + .min(0, "Informe um desconto maior ou igual a zero.") + .optional() + .default(0), + pagadorId: uuidSchema("Pagador").optional(), + categoriaId: uuidSchema("Categoria").optional(), + note: z.string().trim().optional(), +}); + +/** + * Schema de validação para cancelar antecipação + */ +const cancelAnticipationSchema = z.object({ + anticipationId: uuidSchema("Antecipação"), +}); + +/** + * Busca parcelas elegíveis para antecipação de uma série + */ +export async function getEligibleInstallmentsAction( + seriesId: string +): Promise> { + try { + const user = await getUser(); + + // Validar seriesId + const validatedSeriesId = uuidSchema("Série").parse(seriesId); + + // Buscar todas as parcelas da série que estão elegíveis + const rows = await db.query.lancamentos.findMany({ + where: and( + eq(lancamentos.seriesId, validatedSeriesId), + eq(lancamentos.userId, user.id), + eq(lancamentos.condition, "Parcelado"), + // Apenas parcelas não pagas e não antecipadas + or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)), + eq(lancamentos.isAnticipated, false) + ), + orderBy: [asc(lancamentos.currentInstallment)], + columns: { + id: true, + name: true, + amount: true, + period: true, + purchaseDate: true, + dueDate: true, + currentInstallment: true, + installmentCount: true, + paymentMethod: true, + categoriaId: true, + pagadorId: true, + }, + }); + + const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({ + id: row.id, + name: row.name, + amount: row.amount, + period: row.period, + purchaseDate: row.purchaseDate, + dueDate: row.dueDate, + currentInstallment: row.currentInstallment, + installmentCount: row.installmentCount, + paymentMethod: row.paymentMethod, + categoriaId: row.categoriaId, + pagadorId: row.pagadorId, + })); + + return { + success: true, + data: eligibleInstallments, + }; + } catch (error) { + return handleActionError(error); + } +} + +/** + * Cria uma antecipação de parcelas + */ +export async function createInstallmentAnticipationAction( + input: CreateAnticipationInput +): Promise { + try { + const user = await getUser(); + const data = createAnticipationSchema.parse(input); + + // 1. Validar parcelas selecionadas + const installments = await db.query.lancamentos.findMany({ + where: and( + inArray(lancamentos.id, data.installmentIds), + eq(lancamentos.userId, user.id), + eq(lancamentos.seriesId, data.seriesId), + or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)), + eq(lancamentos.isAnticipated, false) + ), + }); + + if (installments.length !== data.installmentIds.length) { + return { + success: false, + error: "Algumas parcelas não estão elegíveis para antecipação.", + }; + } + + if (installments.length === 0) { + return { + success: false, + error: "Nenhuma parcela selecionada para antecipação.", + }; + } + + // 2. Calcular valor total + const totalAmountCents = installments.reduce( + (sum, inst) => sum + Number(inst.amount) * 100, + 0 + ); + const totalAmount = totalAmountCents / 100; + const totalAmountAbs = Math.abs(totalAmount); + + // 2.1. Aplicar desconto + const discount = data.discount || 0; + + // 2.2. Validar que o desconto não é maior que o valor absoluto total + if (discount > totalAmountAbs) { + return { + success: false, + error: "O desconto não pode ser maior que o valor total das parcelas.", + }; + } + + // 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa) + const finalAmount = totalAmount < 0 + ? totalAmount + discount // Despesa: -1000 + 20 = -980 + : totalAmount - discount; // Receita: 1000 - 20 = 980 + + // 3. Pegar dados da primeira parcela para referência + const firstInstallment = installments[0]!; + + // 4. Criar lançamento e antecipação em transação + await db.transaction(async (tx) => { + // 4.1. Criar o lançamento de antecipação (com desconto aplicado) + const [newLancamento] = await tx + .insert(lancamentos) + .values({ + name: generateAnticipationDescription( + firstInstallment.name, + installments.length + ), + condition: "À vista", + transactionType: firstInstallment.transactionType, + paymentMethod: firstInstallment.paymentMethod, + amount: formatDecimalForDbRequired(finalAmount), + purchaseDate: new Date(), + period: data.anticipationPeriod, + dueDate: null, + isSettled: false, + pagadorId: data.pagadorId ?? firstInstallment.pagadorId, + categoriaId: data.categoriaId ?? firstInstallment.categoriaId, + cartaoId: firstInstallment.cartaoId, + contaId: firstInstallment.contaId, + note: + data.note || + generateAnticipationNote( + installments.map((inst) => ({ + id: inst.id, + name: inst.name, + amount: inst.amount, + period: inst.period, + purchaseDate: inst.purchaseDate, + dueDate: inst.dueDate, + currentInstallment: inst.currentInstallment, + installmentCount: inst.installmentCount, + paymentMethod: inst.paymentMethod, + categoriaId: inst.categoriaId, + pagadorId: inst.pagadorId, + })) + ), + userId: user.id, + installmentCount: null, + currentInstallment: null, + recurrenceCount: null, + isAnticipated: false, + isDivided: false, + seriesId: null, + transferId: null, + anticipationId: null, + boletoPaymentDate: null, + }) + .returning(); + + // 4.2. Criar registro de antecipação + const [anticipation] = await tx + .insert(installmentAnticipations) + .values({ + seriesId: data.seriesId, + anticipationPeriod: data.anticipationPeriod, + anticipationDate: new Date(), + anticipatedInstallmentIds: data.installmentIds, + totalAmount: formatDecimalForDbRequired(totalAmount), + installmentCount: installments.length, + discount: formatDecimalForDbRequired(discount), + lancamentoId: newLancamento.id, + pagadorId: data.pagadorId ?? firstInstallment.pagadorId, + categoriaId: data.categoriaId ?? firstInstallment.categoriaId, + note: data.note || null, + userId: user.id, + }) + .returning(); + + // 4.3. Marcar parcelas como antecipadas e zerar seus valores + await tx + .update(lancamentos) + .set({ + isAnticipated: true, + anticipationId: anticipation.id, + amount: "0", // Zera o valor para não contar em dobro + }) + .where(inArray(lancamentos.id, data.installmentIds)); + }); + + revalidatePath("/lancamentos"); + revalidatePath("/dashboard"); + + return { + success: true, + message: `${installments.length} ${ + installments.length === 1 ? "parcela antecipada" : "parcelas antecipadas" + } com sucesso!`, + }; + } catch (error) { + return handleActionError(error); + } +} + +/** + * Busca histórico de antecipações de uma série + */ +export async function getInstallmentAnticipationsAction( + seriesId: string +): Promise> { + try { + const user = await getUser(); + + // Validar seriesId + const validatedSeriesId = uuidSchema("Série").parse(seriesId); + + // Usar query builder ao invés de db.query para evitar problemas de tipagem + const anticipations = await db + .select({ + id: installmentAnticipations.id, + seriesId: installmentAnticipations.seriesId, + anticipationPeriod: installmentAnticipations.anticipationPeriod, + anticipationDate: installmentAnticipations.anticipationDate, + anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds, + totalAmount: installmentAnticipations.totalAmount, + installmentCount: installmentAnticipations.installmentCount, + discount: installmentAnticipations.discount, + lancamentoId: installmentAnticipations.lancamentoId, + pagadorId: installmentAnticipations.pagadorId, + categoriaId: installmentAnticipations.categoriaId, + note: installmentAnticipations.note, + userId: installmentAnticipations.userId, + createdAt: installmentAnticipations.createdAt, + // Joins + lancamento: lancamentos, + pagador: pagadores, + categoria: categorias, + }) + .from(installmentAnticipations) + .leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id)) + .leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id)) + .leftJoin(categorias, eq(installmentAnticipations.categoriaId, categorias.id)) + .where( + and( + eq(installmentAnticipations.seriesId, validatedSeriesId), + eq(installmentAnticipations.userId, user.id) + ) + ) + .orderBy(desc(installmentAnticipations.createdAt)); + + return { + success: true, + data: anticipations, + }; + } catch (error) { + return handleActionError(error); + } +} + +/** + * Cancela uma antecipação de parcelas + * Remove o lançamento de antecipação e restaura as parcelas originais + */ +export async function cancelInstallmentAnticipationAction( + input: CancelAnticipationInput +): Promise { + try { + const user = await getUser(); + const data = cancelAnticipationSchema.parse(input); + + await db.transaction(async (tx) => { + // 1. Buscar antecipação usando query builder + const anticipationRows = await tx + .select({ + id: installmentAnticipations.id, + seriesId: installmentAnticipations.seriesId, + anticipationPeriod: installmentAnticipations.anticipationPeriod, + anticipationDate: installmentAnticipations.anticipationDate, + anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds, + totalAmount: installmentAnticipations.totalAmount, + installmentCount: installmentAnticipations.installmentCount, + discount: installmentAnticipations.discount, + lancamentoId: installmentAnticipations.lancamentoId, + pagadorId: installmentAnticipations.pagadorId, + categoriaId: installmentAnticipations.categoriaId, + note: installmentAnticipations.note, + userId: installmentAnticipations.userId, + createdAt: installmentAnticipations.createdAt, + lancamento: lancamentos, + }) + .from(installmentAnticipations) + .leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id)) + .where( + and( + eq(installmentAnticipations.id, data.anticipationId), + eq(installmentAnticipations.userId, user.id) + ) + ) + .limit(1); + + const anticipation = anticipationRows[0]; + + if (!anticipation) { + throw new Error("Antecipação não encontrada."); + } + + // 2. Verificar se o lançamento já foi pago + if (anticipation.lancamento?.isSettled === true) { + throw new Error( + "Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro." + ); + } + + // 3. Calcular valor original por parcela (totalAmount sem desconto / quantidade) + const originalTotalAmount = Number(anticipation.totalAmount); + const originalValuePerInstallment = + originalTotalAmount / anticipation.installmentCount; + + // 4. Remover flag de antecipação e restaurar valores das parcelas + await tx + .update(lancamentos) + .set({ + isAnticipated: false, + anticipationId: null, + amount: formatDecimalForDbRequired(originalValuePerInstallment), + }) + .where( + inArray( + lancamentos.id, + anticipation.anticipatedInstallmentIds as string[] + ) + ); + + // 5. Deletar lançamento de antecipação + await tx + .delete(lancamentos) + .where(eq(lancamentos.id, anticipation.lancamentoId)); + + // 6. Deletar registro de antecipação + await tx + .delete(installmentAnticipations) + .where(eq(installmentAnticipations.id, data.anticipationId)); + }); + + revalidatePath("/lancamentos"); + revalidatePath("/dashboard"); + + return { + success: true, + message: "Antecipação cancelada com sucesso!", + }; + } catch (error) { + return handleActionError(error); + } +} + +/** + * Busca detalhes de uma antecipação específica + */ +export async function getAnticipationDetailsAction( + anticipationId: string +): Promise> { + try { + const user = await getUser(); + + // Validar anticipationId + const validatedId = uuidSchema("Antecipação").parse(anticipationId); + + const anticipation = await db.query.installmentAnticipations.findFirst({ + where: and( + eq(installmentAnticipations.id, validatedId), + eq(installmentAnticipations.userId, user.id) + ), + with: { + lancamento: true, + pagador: true, + categoria: true, + }, + }); + + if (!anticipation) { + return { + success: false, + error: "Antecipação não encontrada.", + }; + } + + return { + success: true, + data: anticipation, + }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/lancamentos/data.ts b/app/(dashboard)/lancamentos/data.ts new file mode 100644 index 0000000..d37d21b --- /dev/null +++ b/app/(dashboard)/lancamentos/data.ts @@ -0,0 +1,18 @@ +import { lancamentos } from "@/db/schema"; +import { db } from "@/lib/db"; +import { and, desc, type SQL } from "drizzle-orm"; + +export async function fetchLancamentos(filters: SQL[]) { + const lancamentoRows = await db.query.lancamentos.findMany({ + where: and(...filters), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)], + }); + + return lancamentoRows; +} diff --git a/app/(dashboard)/lancamentos/layout.tsx b/app/(dashboard)/lancamentos/layout.tsx new file mode 100644 index 0000000..5b7c7bd --- /dev/null +++ b/app/(dashboard)/lancamentos/layout.tsx @@ -0,0 +1,25 @@ +import PageDescription from "@/components/page-description"; +import { RiArrowLeftRightLine } from "@remixicon/react"; + +export const metadata = { + title: "Lançamentos | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Lançamentos" + subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo + receitas, despesas e transações previstas. Use o seletor abaixo para + navegar pelos meses e visualizar as movimentações correspondentes." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/lancamentos/loading.tsx b/app/(dashboard)/lancamentos/loading.tsx new file mode 100644 index 0000000..8d6aab8 --- /dev/null +++ b/app/(dashboard)/lancamentos/loading.tsx @@ -0,0 +1,32 @@ +import { + FilterSkeleton, + TransactionsTableSkeleton, +} from "@/components/skeletons"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de lançamentos + * Mantém o mesmo layout da página final + */ +export default function LancamentosLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ +
+ {/* Header com título e botão */} +
+ + +
+ + {/* Filtros */} + + + {/* Tabela */} + +
+
+ ); +} diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx new file mode 100644 index 0000000..1f2ede2 --- /dev/null +++ b/app/(dashboard)/lancamentos/page.tsx @@ -0,0 +1,84 @@ +import MonthPicker from "@/components/month-picker/month-picker"; +import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; +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 { fetchLancamentos } from "./data"; +import { getRecentEstablishmentsAction } from "./actions"; + +type PageSearchParams = Promise; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +export default async function Page({ searchParams }: PageProps) { + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw); + + const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); + + const filterSources = await fetchLancamentoFilterSources(userId); + const sluggedFilters = buildSluggedFilters(filterSources); + const slugMaps = buildSlugMaps(sluggedFilters); + + const filters = buildLancamentoWhere({ + userId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + }); + + const lancamentoRows = await fetchLancamentos(filters); + const lancamentosData = mapLancamentosData(lancamentoRows); + + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); + + const estabelecimentos = await getRecentEstablishmentsAction(); + + return ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..d9529eb --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,67 @@ +import { SiteHeader } from "@/components/header-dashboard"; +import { AppSidebar } from "@/components/sidebar/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { fetchDashboardNotifications } from "@/lib/dashboard/notifications"; +import { getUserSession } from "@/lib/auth/server"; +import { parsePeriodParam } from "@/lib/utils/period"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; + +export default async function layout({ + children, + searchParams, +}: Readonly<{ + children: React.ReactNode; + searchParams?: Promise>; +}>) { + const session = await getUserSession(); + const pagadoresList = await fetchPagadoresWithAccess(session.user.id); + + // Encontrar o pagador admin do usuário + const adminPagador = pagadoresList.find( + (p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id + ); + + // Buscar notificações para o período atual + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = resolvedSearchParams?.periodo; + const singlePeriodoParam = + typeof periodoParam === "string" + ? periodoParam + : Array.isArray(periodoParam) + ? periodoParam[0] + : null; + const { period: currentPeriod } = parsePeriodParam( + singlePeriodoParam ?? null + ); + const notificationsSnapshot = await fetchDashboardNotifications( + session.user.id, + currentPeriod + ); + + return ( + + ({ + id: item.id, + name: item.name, + avatarUrl: item.avatarUrl, + canEdit: item.canEdit, + }))} + variant="inset" + /> + + +
+
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/app/(dashboard)/orcamentos/actions.ts b/app/(dashboard)/orcamentos/actions.ts new file mode 100644 index 0000000..49ec068 --- /dev/null +++ b/app/(dashboard)/orcamentos/actions.ts @@ -0,0 +1,190 @@ +"use server"; + +import { categorias, orcamentos } from "@/db/schema"; +import { + type ActionResult, + handleActionError, + revalidateForEntity, +} from "@/lib/actions/helpers"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { periodSchema, uuidSchema } from "@/lib/schemas/common"; +import { + formatDecimalForDbRequired, + normalizeDecimalInput, +} from "@/lib/utils/currency"; +import { and, eq, ne } from "drizzle-orm"; +import { z } from "zod"; + +const budgetBaseSchema = z.object({ + categoriaId: uuidSchema("Categoria"), + period: periodSchema, + amount: z + .string({ message: "Informe o valor limite." }) + .trim() + .min(1, "Informe o valor limite.") + .transform((value) => normalizeDecimalInput(value)) + .refine( + (value) => !Number.isNaN(Number.parseFloat(value)), + "Informe um valor limite válido." + ) + .transform((value) => Number.parseFloat(value)) + .refine( + (value) => value >= 0, + "O valor limite deve ser maior ou igual a zero." + ), +}); + +const createBudgetSchema = budgetBaseSchema; +const updateBudgetSchema = budgetBaseSchema.extend({ + id: uuidSchema("Orçamento"), +}); +const deleteBudgetSchema = z.object({ + id: uuidSchema("Orçamento"), +}); + +type BudgetCreateInput = z.infer; +type BudgetUpdateInput = z.infer; +type BudgetDeleteInput = z.infer; + +const ensureCategory = async (userId: string, categoriaId: string) => { + const category = await db.query.categorias.findFirst({ + columns: { + id: true, + type: true, + }, + where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), + }); + + if (!category) { + throw new Error("Categoria não encontrada."); + } + + if (category.type !== "despesa") { + throw new Error("Selecione uma categoria de despesa."); + } +}; + +export async function createBudgetAction( + input: BudgetCreateInput +): Promise { + try { + const user = await getUser(); + const data = createBudgetSchema.parse(input); + + await ensureCategory(user.id, data.categoriaId); + + const duplicateConditions = [ + eq(orcamentos.userId, user.id), + eq(orcamentos.period, data.period), + eq(orcamentos.categoriaId, data.categoriaId), + ] as const; + + const duplicate = await db.query.orcamentos.findFirst({ + columns: { id: true }, + where: and(...duplicateConditions), + }); + + if (duplicate) { + return { + success: false, + error: + "Já existe um orçamento para esta categoria no período selecionado.", + }; + } + + await db.insert(orcamentos).values({ + amount: formatDecimalForDbRequired(data.amount), + period: data.period, + userId: user.id, + categoriaId: data.categoriaId, + }); + + revalidateForEntity("orcamentos"); + + return { success: true, message: "Orçamento criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updateBudgetAction( + input: BudgetUpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateBudgetSchema.parse(input); + + await ensureCategory(user.id, data.categoriaId); + + const duplicateConditions = [ + eq(orcamentos.userId, user.id), + eq(orcamentos.period, data.period), + eq(orcamentos.categoriaId, data.categoriaId), + ne(orcamentos.id, data.id), + ] as const; + + const duplicate = await db.query.orcamentos.findFirst({ + columns: { id: true }, + where: and(...duplicateConditions), + }); + + if (duplicate) { + return { + success: false, + error: + "Já existe um orçamento para esta categoria no período selecionado.", + }; + } + + const [updated] = await db + .update(orcamentos) + .set({ + amount: formatDecimalForDbRequired(data.amount), + period: data.period, + categoriaId: data.categoriaId, + }) + .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) + .returning({ id: orcamentos.id }); + + if (!updated) { + return { + success: false, + error: "Orçamento não encontrado.", + }; + } + + revalidateForEntity("orcamentos"); + + return { success: true, message: "Orçamento atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteBudgetAction( + input: BudgetDeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteBudgetSchema.parse(input); + + const [deleted] = await db + .delete(orcamentos) + .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) + .returning({ id: orcamentos.id }); + + if (!deleted) { + return { + success: false, + error: "Orçamento não encontrado.", + }; + } + + revalidateForEntity("orcamentos"); + + return { success: true, message: "Orçamento removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/orcamentos/data.ts b/app/(dashboard)/orcamentos/data.ts new file mode 100644 index 0000000..a2347eb --- /dev/null +++ b/app/(dashboard)/orcamentos/data.ts @@ -0,0 +1,125 @@ +import { + categorias, + lancamentos, + orcamentos, + type Orcamento, +} from "@/db/schema"; +import { db } from "@/lib/db"; +import { and, asc, eq, inArray, sum } from "drizzle-orm"; + +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(); + + if (categoryIds.length > 0) { + const totals = await db + .select({ + categoriaId: lancamentos.categoriaId, + totalAmount: sum(lancamentos.amount).as("totalAmount"), + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, selectedPeriod), + eq(lancamentos.transactionType, "Despesa"), + inArray(lancamentos.categoriaId, categoryIds) + ) + ) + .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 }; +} diff --git a/app/(dashboard)/orcamentos/layout.tsx b/app/(dashboard)/orcamentos/layout.tsx new file mode 100644 index 0000000..c4061b5 --- /dev/null +++ b/app/(dashboard)/orcamentos/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiFundsLine } from "@remixicon/react"; + +export const metadata = { + title: "Anotações | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Orçamentos" + subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/orcamentos/loading.tsx b/app/(dashboard)/orcamentos/loading.tsx new file mode 100644 index 0000000..45fc824 --- /dev/null +++ b/app/(dashboard)/orcamentos/loading.tsx @@ -0,0 +1,68 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de orçamentos + * Layout: MonthPicker + Header + Grid de cards de orçamento + */ +export default function OrcamentosLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ +
+ {/* Header */} +
+
+ + +
+ +
+ + {/* Grid de cards de orçamentos */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Categoria com ícone */} +
+ +
+ + +
+
+ + {/* Valor orçado */} +
+ + +
+ + {/* Valor gasto */} +
+ + +
+ + {/* Barra de progresso */} +
+ + +
+ + {/* Botões de ação */} +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/orcamentos/page.tsx b/app/(dashboard)/orcamentos/page.tsx new file mode 100644 index 0000000..4ed9370 --- /dev/null +++ b/app/(dashboard)/orcamentos/page.tsx @@ -0,0 +1,55 @@ +import MonthPicker from "@/components/month-picker/month-picker"; +import { BudgetsPage } from "@/components/orcamentos/budgets-page"; +import { getUserId } from "@/lib/auth/server"; +import { parsePeriodParam } from "@/lib/utils/period"; +import { fetchBudgetsForUser } from "./data"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? value[0] ?? null : value; +}; + +const capitalize = (value: string) => + value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1); + +export default async function Page({ searchParams }: PageProps) { + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + + const { + period: selectedPeriod, + monthName: rawMonthName, + year, + } = parsePeriodParam(periodoParam); + + const periodLabel = `${capitalize(rawMonthName)} ${year}`; + + const { budgets, categoriesOptions } = await fetchBudgetsForUser( + userId, + selectedPeriod + ); + + return ( +
+ + +
+ ); +} + diff --git a/app/(dashboard)/pagadores/[pagadorId]/actions.ts b/app/(dashboard)/pagadores/[pagadorId]/actions.ts new file mode 100644 index 0000000..a0b22b1 --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/actions.ts @@ -0,0 +1,612 @@ +"use server"; + +import { lancamentos, pagadores } from "@/db/schema"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { + fetchPagadorBoletoStats, + fetchPagadorCardUsage, + fetchPagadorHistory, + fetchPagadorMonthlyBreakdown, +} from "@/lib/pagadores/details"; +import { and, desc, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { Resend } from "resend"; +import { z } from "zod"; + +const inputSchema = z.object({ + pagadorId: z.string().uuid("Pagador inválido."), + period: z + .string() + .regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."), +}); + +type ActionResult = + | { success: true; message: string } + | { success: false; error: string }; + +const formatCurrency = (value: number) => + value.toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + maximumFractionDigits: 2, + }); + +const formatPeriodLabel = (period: string) => { + const [yearStr, monthStr] = period.split("-"); + const year = Number.parseInt(yearStr, 10); + const month = Number.parseInt(monthStr, 10) - 1; + const date = new Date(year, month, 1); + return date.toLocaleDateString("pt-BR", { + month: "long", + year: "numeric", + }); +}; + +const formatDate = (value: Date | null | undefined) => { + if (!value) return "—"; + return value.toLocaleDateString("pt-BR", { + day: "2-digit", + month: "short", + year: "numeric", + }); +}; + +// Escapa HTML para prevenir XSS +const escapeHtml = (text: string | null | undefined): string => { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; + +type LancamentoRow = { + id: string; + name: string | null; + paymentMethod: string | null; + condition: string | null; + amount: number; + transactionType: string | null; + purchaseDate: Date | null; +}; + +type BoletoItem = { + name: string; + amount: number; + dueDate: Date | null; +}; + +type ParceladoItem = { + name: string; + totalAmount: number; + installmentCount: number; + currentInstallment: number; + installmentAmount: number; + purchaseDate: Date | null; +}; + +type SummaryPayload = { + pagadorName: string; + periodLabel: string; + monthlyBreakdown: Awaited>; + historyData: Awaited>; + cardUsage: Awaited>; + boletoStats: Awaited>; + boletos: BoletoItem[]; + lancamentos: LancamentoRow[]; + parcelados: ParceladoItem[]; +}; + +const buildSectionHeading = (label: string) => + `

${label}

`; + +const buildSummaryHtml = ({ + pagadorName, + periodLabel, + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + boletos, + lancamentos, + parcelados, +}: SummaryPayload) => { + // Calcular máximo de despesas para barras de progresso + const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1); + + const historyRows = + historyData.length > 0 + ? historyData + .map((point) => { + const percentage = (point.despesas / maxDespesas) * 100; + const barColor = + point.despesas > maxDespesas * 0.8 + ? "#ef4444" + : point.despesas > maxDespesas * 0.5 + ? "#f59e0b" + : "#10b981"; + + return ` + + ${escapeHtml( + point.label + )} + +
+
+
+
+ ${formatCurrency( + point.despesas + )} +
+ + `; + }) + .join("") + : `Sem histórico suficiente.`; + + const cardUsageRows = + cardUsage.length > 0 + ? cardUsage + .map( + (item) => ` + + ${escapeHtml( + item.name + )} + ${formatCurrency( + item.amount + )} + ` + ) + .join("") + : `Sem gastos com cartão neste período.`; + + const boletoRows = + boletos.length > 0 + ? boletos + .map( + (item) => ` + + ${escapeHtml( + item.name + )} + ${ + item.dueDate ? formatDate(item.dueDate) : "—" + } + ${formatCurrency( + item.amount + )} + ` + ) + .join("") + : `Sem boletos neste período.`; + + const lancamentoRows = + lancamentos.length > 0 + ? lancamentos + .map( + (item) => ` + + ${formatDate( + item.purchaseDate + )} + ${ + escapeHtml(item.name) || "Sem descrição" + } + ${ + escapeHtml(item.condition) || "—" + } + ${ + escapeHtml(item.paymentMethod) || "—" + } + ${formatCurrency( + item.amount + )} + ` + ) + .join("") + : `Nenhum lançamento registrado no período.`; + + const parceladoRows = + parcelados.length > 0 + ? parcelados + .map( + (item) => ` + + ${formatDate( + item.purchaseDate + )} + ${ + escapeHtml(item.name) || "Sem descrição" + } + ${ + item.currentInstallment + }/${item.installmentCount} + ${formatCurrency( + item.installmentAmount + )} + ${formatCurrency( + item.totalAmount + )} + ` + ) + .join("") + : `Nenhum lançamento parcelado neste período.`; + + return ` +
+ + Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos. + + +
+

Resumo Financeiro

+

${escapeHtml( + periodLabel + )}

+
+ + +
+ +

+ Olá ${escapeHtml( + pagadorName + )}, segue o consolidado do mês: +

+ + + ${buildSectionHeading("💰 Totais do mês")} + + + + + + + + + + + + + + + + + + + +
Total gasto + ${formatCurrency( + monthlyBreakdown.totalExpenses + )} +
💳 Cartões${formatCurrency( + monthlyBreakdown.paymentSplits.card + )}
📄 Boletos${formatCurrency( + monthlyBreakdown.paymentSplits.boleto + )}
⚡ Pix/Débito/Dinheiro${formatCurrency( + monthlyBreakdown.paymentSplits.instant + )}
+ + + ${buildSectionHeading("📊 Evolução das Despesas (6 meses)")} + + + + + + + + ${historyRows} +
PeríodoValor
+ + + ${buildSectionHeading("💳 Gastos com Cartões")} + + + + +
+ + + + + +
Total + ${formatCurrency( + monthlyBreakdown.paymentSplits.card + )} +
+
+ + + + + + + + ${cardUsageRows} +
CartãoValor
+ + + ${buildSectionHeading("📄 Boletos")} + + + + +
+ + + + + +
Total + ${formatCurrency( + boletoStats.totalAmount + )} +
+
+ + + + + + + + + ${boletoRows} +
DescriçãoVencimentoValor
+ + + ${buildSectionHeading("📝 Lançamentos do Mês")} + + + + + + + + + + + ${lancamentoRows} +
DataDescriçãoCondiçãoPagamentoValor
+ + + ${buildSectionHeading("💳 Lançamentos Parcelados")} + + + + + + + + + + + ${parceladoRows} +
DataDescriçãoParcelaValor ParcelaTotal
+ + +
+
+ + +

+ Este e-mail foi enviado automaticamente pelo OpenSheets. +

+
+ + `; +}; + +export async function sendPagadorSummaryAction( + input: z.infer +): Promise { + try { + const { pagadorId, period } = inputSchema.parse(input); + const user = await getUser(); + + const pagadorRow = await db.query.pagadores.findFirst({ + where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)), + }); + + if (!pagadorRow) { + return { success: false, error: "Pagador não encontrado." }; + } + + if (!pagadorRow.email) { + return { + success: false, + error: "Cadastre um e-mail para conseguir enviar o resumo.", + }; + } + + const resendApiKey = process.env.RESEND_API_KEY; + const resendFrom = + process.env.RESEND_FROM_EMAIL ?? "OpenSheets "; + + if (!resendApiKey) { + return { + success: false, + error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).", + }; + } + + const resend = new Resend(resendApiKey); + + const [ + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + boletoRows, + lancamentoRows, + parceladoRows, + ] = await Promise.all([ + fetchPagadorMonthlyBreakdown({ + userId: user.id, + pagadorId, + period, + }), + fetchPagadorHistory({ + userId: user.id, + pagadorId, + period, + }), + fetchPagadorCardUsage({ + userId: user.id, + pagadorId, + period, + }), + fetchPagadorBoletoStats({ + userId: user.id, + pagadorId, + period, + }), + db + .select({ + name: lancamentos.name, + amount: lancamentos.amount, + dueDate: lancamentos.dueDate, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.pagadorId, pagadorId), + eq(lancamentos.period, period), + eq(lancamentos.paymentMethod, "Boleto") + ) + ) + .orderBy(desc(lancamentos.dueDate)), + db + .select({ + id: lancamentos.id, + name: lancamentos.name, + paymentMethod: lancamentos.paymentMethod, + condition: lancamentos.condition, + amount: lancamentos.amount, + transactionType: lancamentos.transactionType, + purchaseDate: lancamentos.purchaseDate, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.pagadorId, pagadorId), + eq(lancamentos.period, period) + ) + ) + .orderBy(desc(lancamentos.purchaseDate)), + db + .select({ + name: lancamentos.name, + amount: lancamentos.amount, + installmentCount: lancamentos.installmentCount, + currentInstallment: lancamentos.currentInstallment, + purchaseDate: lancamentos.purchaseDate, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.pagadorId, pagadorId), + eq(lancamentos.period, period), + eq(lancamentos.condition, "Parcelado"), + eq(lancamentos.isAnticipated, false) + ) + ) + .orderBy(desc(lancamentos.purchaseDate)), + ]); + + const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({ + name: row.name ?? "Sem descrição", + amount: Math.abs(Number(row.amount ?? 0)), + dueDate: row.dueDate, + })); + + const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( + (row) => ({ + id: row.id, + name: row.name, + paymentMethod: row.paymentMethod, + condition: row.condition, + transactionType: row.transactionType, + purchaseDate: row.purchaseDate, + amount: Number(row.amount ?? 0), + }) + ); + + const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => { + const installmentAmount = Math.abs(Number(row.amount ?? 0)); + const installmentCount = row.installmentCount ?? 1; + const totalAmount = installmentAmount * installmentCount; + + return { + name: row.name ?? "Sem descrição", + installmentAmount, + installmentCount, + currentInstallment: row.currentInstallment ?? 1, + totalAmount, + purchaseDate: row.purchaseDate, + }; + }); + + const html = buildSummaryHtml({ + pagadorName: pagadorRow.name, + periodLabel: formatPeriodLabel(period), + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + boletos: normalizedBoletos, + lancamentos: normalizedLancamentos, + parcelados: normalizedParcelados, + }); + + await resend.emails.send({ + from: resendFrom, + to: pagadorRow.email, + subject: `Resumo Financeiro | ${formatPeriodLabel(period)}`, + html, + }); + + const now = new Date(); + + await db + .update(pagadores) + .set({ lastMailAt: now }) + .where( + and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)) + ); + + revalidatePath(`/pagadores/${pagadorRow.id}`); + + return { success: true, message: "Resumo enviado com sucesso." }; + } catch (error) { + // Log estruturado em desenvolvimento + if (process.env.NODE_ENV === "development") { + console.error("[sendPagadorSummaryAction]", error); + } + + // Tratar erros de validação separadamente + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } + + // Não expor detalhes do erro para o usuário + return { + success: false, + error: "Não foi possível enviar o resumo. Tente novamente mais tarde.", + }; + } +} diff --git a/app/(dashboard)/pagadores/[pagadorId]/data.ts b/app/(dashboard)/pagadores/[pagadorId]/data.ts new file mode 100644 index 0000000..44ad5ac --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/data.ts @@ -0,0 +1,53 @@ +import { lancamentos, pagadorShares, user as usersTable } from "@/db/schema"; +import { db } from "@/lib/db"; +import { and, desc, eq, type SQL } from "drizzle-orm"; + +export type ShareData = { + id: string; + userId: string; + name: string; + email: string; + createdAt: string; +}; + +export async function fetchPagadorShares( + pagadorId: string +): Promise { + const shareRows = await db + .select({ + id: pagadorShares.id, + sharedWithUserId: pagadorShares.sharedWithUserId, + createdAt: pagadorShares.createdAt, + userName: usersTable.name, + userEmail: usersTable.email, + }) + .from(pagadorShares) + .innerJoin( + usersTable, + eq(pagadorShares.sharedWithUserId, usersTable.id) + ) + .where(eq(pagadorShares.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 fetchPagadorLancamentos(filters: SQL[]) { + const lancamentoRows = await db.query.lancamentos.findMany({ + where: and(...filters), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + orderBy: desc(lancamentos.purchaseDate), + }); + + return lancamentoRows; +} diff --git a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx new file mode 100644 index 0000000..555c0dc --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx @@ -0,0 +1,84 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de detalhes do pagador + * Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos) + */ +export default function PagadorDetailsLoading() { + return ( +
+ {/* Month Picker placeholder */} +
+ + {/* Info do Pagador (sempre visível) */} +
+
+ {/* Avatar */} + + +
+ {/* Nome + Badge */} +
+ + +
+ + {/* Email */} + + + {/* Status */} +
+ + +
+
+ + {/* Botões de ação */} +
+ + +
+
+
+ + {/* Tabs */} +
+
+ + +
+ + {/* Conteúdo da aba Visão Geral (grid de cards) */} +
+ {/* Card de resumo mensal */} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Outros cards */} + {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx new file mode 100644 index 0000000..d0f2d4d --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -0,0 +1,384 @@ +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 { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card"; +import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards"; +import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card"; +import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; +import type { + ContaCartaoFilterOption, + LancamentoFilterOption, + LancamentoItem, + SelectOption, +} from "@/components/lancamentos/types"; +import MonthPicker from "@/components/month-picker/month-picker"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { pagadores } from "@/db/schema"; +import { getUserId } from "@/lib/auth/server"; +import { + buildLancamentoWhere, + buildOptionSets, + buildSluggedFilters, + buildSlugMaps, + extractLancamentoSearchFilters, + fetchLancamentoFilterSources, + getSingleParam, + mapLancamentosData, + type LancamentoSearchFilters, + type ResolvedSearchParams, + type SlugMaps, + type SluggedFilters, +} from "@/lib/lancamentos/page-helpers"; +import { parsePeriodParam } from "@/lib/utils/period"; +import { getPagadorAccess } from "@/lib/pagadores/access"; +import { + fetchPagadorBoletoStats, + fetchPagadorCardUsage, + fetchPagadorHistory, + fetchPagadorMonthlyBreakdown, +} from "@/lib/pagadores/details"; +import { notFound } from "next/navigation"; +import { fetchPagadorLancamentos, fetchPagadorShares } from "./data"; + +type PageSearchParams = Promise; + +type PageProps = { + params: Promise<{ pagadorId: string }>; + searchParams?: PageSearchParams; +}; + +const capitalize = (value: string) => + value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value; + +const EMPTY_FILTERS: LancamentoSearchFilters = { + transactionFilter: null, + conditionFilter: null, + paymentFilter: null, + pagadorFilter: null, + categoriaFilter: null, + contaCartaoFilter: null, + searchFilter: null, +}; + +const createEmptySlugMaps = (): SlugMaps => ({ + pagador: new Map(), + categoria: new Map(), + conta: new Map(), + cartao: new Map(), +}); + +type OptionSet = ReturnType; + +export default async function Page({ params, searchParams }: PageProps) { + const { pagadorId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + + const access = await getPagadorAccess(userId, pagadorId); + + if (!access) { + notFound(); + } + + const { pagador, canEdit } = access; + const dataOwnerId = pagador.userId; + + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { + period: selectedPeriod, + monthName, + year, + } = parsePeriodParam(periodoParamRaw); + const periodLabel = `${capitalize(monthName)} de ${year}`; + + const searchFilters = canEdit + ? extractLancamentoSearchFilters(resolvedSearchParams) + : EMPTY_FILTERS; + + let filterSources: Awaited< + ReturnType + > | null = null; + let sluggedFilters: SluggedFilters; + let slugMaps: SlugMaps; + + if (canEdit) { + filterSources = await fetchLancamentoFilterSources(dataOwnerId); + sluggedFilters = buildSluggedFilters(filterSources); + slugMaps = buildSlugMaps(sluggedFilters); + } else { + 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 [ + lancamentoRows, + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + shareRows, + ] = 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, + }), + sharesPromise, + ]); + + const mappedLancamentos = mapLancamentosData(lancamentoRows); + const lancamentosData = canEdit + ? mappedLancamentos + : mappedLancamentos.map((item) => ({ ...item, readonly: true })); + + const pagadorSharesData = shareRows; + + let optionSets: OptionSet; + 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); + } + + const pagadorSlug = + effectiveSluggedFilters.pagadorFiltersRaw.find( + (item) => item.id === pagador.id + )?.slug ?? null; + + const pagadorFilterOptions = pagadorSlug + ? optionSets.pagadorFilterOptions.filter( + (option) => option.slug === pagadorSlug + ) + : optionSets.pagadorFilterOptions; + + const pagadorData = { + id: pagador.id, + name: pagador.name, + email: pagador.email ?? null, + avatarUrl: pagador.avatarUrl ?? null, + status: pagador.status, + note: pagador.note ?? null, + role: pagador.role ?? null, + isAutoSend: pagador.isAutoSend ?? false, + createdAt: pagador.createdAt + ? pagador.createdAt.toISOString() + : new Date().toISOString(), + lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null, + shareCode: canEdit ? pagador.shareCode : null, + canEdit, + }; + + const summaryPreview = { + periodLabel, + totalExpenses: monthlyBreakdown.totalExpenses, + paymentSplits: monthlyBreakdown.paymentSplits, + cardUsage: cardUsage.slice(0, 3).map((item) => ({ + name: item.name, + amount: item.amount, + })), + boletoStats: { + totalAmount: boletoStats.totalAmount, + paidAmount: boletoStats.paidAmount, + pendingAmount: boletoStats.pendingAmount, + paidCount: boletoStats.paidCount, + pendingCount: boletoStats.pendingCount, + }, + lancamentoCount: lancamentosData.length, + }; + + return ( +
+ + + + + Perfil + Painel + Lançamentos + + + +
+ +
+ {canEdit && pagadorData.shareCode ? ( + + ) : null} +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+
+
+ ); +} + +const normalizeOptionLabel = (value: string | null | undefined, fallback: string) => + value?.trim().length ? value.trim() : fallback; + +function buildReadOnlyOptionSets( + items: LancamentoItem[], + pagador: typeof pagadores.$inferSelect +): OptionSet { + const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador"); + const pagadorOptions: SelectOption[] = [ + { + value: pagador.id, + label: pagadorLabel, + slug: pagador.id, + }, + ]; + + const contaOptionsMap = new Map(); + const cartaoOptionsMap = new Map(); + const categoriaOptionsMap = new Map(); + + items.forEach((item) => { + if (item.contaId && !contaOptionsMap.has(item.contaId)) { + contaOptionsMap.set(item.contaId, { + value: item.contaId, + label: normalizeOptionLabel(item.contaName, "Conta sem nome"), + slug: item.contaId, + }); + } + if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) { + cartaoOptionsMap.set(item.cartaoId, { + value: item.cartaoId, + label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"), + slug: item.cartaoId, + }); + } + if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) { + categoriaOptionsMap.set(item.categoriaId, { + value: item.categoriaId, + label: normalizeOptionLabel(item.categoriaName, "Categoria"), + slug: item.categoriaId, + }); + } + }); + + const contaOptions = Array.from(contaOptionsMap.values()); + const cartaoOptions = Array.from(cartaoOptionsMap.values()); + const categoriaOptions = Array.from(categoriaOptionsMap.values()); + + const pagadorFilterOptions: LancamentoFilterOption[] = [ + { slug: pagador.id, label: pagadorLabel }, + ]; + + const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map( + (option) => ({ + slug: option.value, + label: option.label, + }) + ); + + const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [ + ...contaOptions.map((option) => ({ + slug: option.value, + label: option.label, + kind: "conta" as const, + })), + ...cartaoOptions.map((option) => ({ + slug: option.value, + label: option.label, + kind: "cartao" as const, + })), + ]; + + return { + pagadorOptions, + splitPagadorOptions: [], + defaultPagadorId: pagador.id, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + }; +} diff --git a/app/(dashboard)/pagadores/actions.ts b/app/(dashboard)/pagadores/actions.ts new file mode 100644 index 0000000..93e2999 --- /dev/null +++ b/app/(dashboard)/pagadores/actions.ts @@ -0,0 +1,337 @@ +"use server"; + +import { pagadores, pagadorShares } from "@/db/schema"; +import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; +import type { ActionResult } from "@/lib/actions/types"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { + DEFAULT_PAGADOR_AVATAR, + PAGADOR_ROLE_ADMIN, + PAGADOR_ROLE_TERCEIRO, + PAGADOR_STATUS_OPTIONS, +} from "@/lib/pagadores/constants"; +import { normalizeAvatarPath } from "@/lib/pagadores/utils"; +import { noteSchema, uuidSchema } from "@/lib/schemas/common"; +import { normalizeOptionalString } from "@/lib/utils/string"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { randomBytes } from "node:crypto"; +import { z } from "zod"; + +const statusEnum = z.enum(PAGADOR_STATUS_OPTIONS as [string, ...string[]], { + errorMap: () => ({ + message: "Selecione um status válido.", + }), +}); + +const baseSchema = z.object({ + name: z + .string({ message: "Informe o nome do pagador." }) + .trim() + .min(1, "Informe o nome do pagador."), + email: z + .string() + .trim() + .email("Informe um e-mail válido.") + .optional() + .transform((value) => normalizeOptionalString(value)), + status: statusEnum, + note: noteSchema, + avatarUrl: z.string().trim().optional(), + isAutoSend: z.boolean().optional().default(false), +}); + +const createSchema = baseSchema; + +const updateSchema = baseSchema.extend({ + id: uuidSchema("Pagador"), +}); + +const deleteSchema = z.object({ + id: uuidSchema("Pagador"), +}); + +const shareDeleteSchema = z.object({ + shareId: uuidSchema("Compartilhamento"), +}); + +const shareCodeJoinSchema = z.object({ + code: z + .string({ message: "Informe o código." }) + .trim() + .min(8, "Código inválido."), +}); + +const shareCodeRegenerateSchema = z.object({ + pagadorId: uuidSchema("Pagador"), +}); + +type CreateInput = z.infer; +type UpdateInput = z.infer; +type DeleteInput = z.infer; +type ShareDeleteInput = z.infer; +type ShareCodeJoinInput = z.infer; +type ShareCodeRegenerateInput = z.infer; + +const revalidate = () => revalidateForEntity("pagadores"); + +const generateShareCode = () => { + // base64url já retorna apenas [a-zA-Z0-9_-] + // 18 bytes = 24 caracteres em base64 + return randomBytes(18).toString("base64url").slice(0, 24); +}; + +export async function createPagadorAction( + input: CreateInput +): Promise { + try { + const user = await getUser(); + const data = createSchema.parse(input); + + await db.insert(pagadores).values({ + name: data.name, + email: data.email, + status: data.status, + note: data.note, + avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR, + isAutoSend: data.isAutoSend ?? false, + role: PAGADOR_ROLE_TERCEIRO, + shareCode: generateShareCode(), + userId: user.id, + }); + + revalidate(); + + return { success: true, message: "Pagador criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updatePagadorAction( + input: UpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateSchema.parse(input); + + const existing = await db.query.pagadores.findFirst({ + where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)), + }); + + if (!existing) { + return { + success: false, + error: "Pagador não encontrado.", + }; + } + + await db + .update(pagadores) + .set({ + name: data.name, + email: data.email, + status: data.status, + note: data.note, + avatarUrl: + normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null, + isAutoSend: data.isAutoSend ?? false, + role: existing.role ?? PAGADOR_ROLE_TERCEIRO, + }) + .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id))); + + revalidate(); + + return { success: true, message: "Pagador atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deletePagadorAction( + input: DeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteSchema.parse(input); + + const existing = await db.query.pagadores.findFirst({ + where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)), + }); + + if (!existing) { + return { + success: false, + error: "Pagador não encontrado.", + }; + } + + if (existing.role === PAGADOR_ROLE_ADMIN) { + return { + success: false, + error: "Pagadores administradores não podem ser removidos.", + }; + } + + await db + .delete(pagadores) + .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id))); + + revalidate(); + + return { success: true, message: "Pagador removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function joinPagadorByShareCodeAction( + input: ShareCodeJoinInput +): Promise { + try { + const user = await getUser(); + const data = shareCodeJoinSchema.parse(input); + + const pagadorRow = await db.query.pagadores.findFirst({ + where: eq(pagadores.shareCode, data.code), + }); + + if (!pagadorRow) { + return { success: false, error: "Código inválido ou expirado." }; + } + + if (pagadorRow.userId === user.id) { + return { + success: false, + error: "Você já é o proprietário deste pagador.", + }; + } + + const existingShare = await db.query.pagadorShares.findFirst({ + where: and( + eq(pagadorShares.pagadorId, pagadorRow.id), + eq(pagadorShares.sharedWithUserId, user.id) + ), + }); + + if (existingShare) { + return { + success: false, + error: "Você já possui acesso a este pagador.", + }; + } + + await db.insert(pagadorShares).values({ + pagadorId: pagadorRow.id, + sharedWithUserId: user.id, + permission: "read", + createdByUserId: pagadorRow.userId, + }); + + revalidate(); + + return { success: true, message: "Pagador adicionado à sua lista." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deletePagadorShareAction( + input: ShareDeleteInput +): Promise { + try { + const user = await getUser(); + const data = shareDeleteSchema.parse(input); + + const existing = await db.query.pagadorShares.findFirst({ + columns: { + id: true, + pagadorId: true, + sharedWithUserId: true, + }, + where: eq(pagadorShares.id, data.shareId), + with: { + pagador: { + columns: { + userId: true, + }, + }, + }, + }); + + // Permitir que o owner OU o próprio usuário compartilhado remova o share + if (!existing || (existing.pagador.userId !== user.id && existing.sharedWithUserId !== user.id)) { + return { + success: false, + error: "Compartilhamento não encontrado.", + }; + } + + await db + .delete(pagadorShares) + .where(eq(pagadorShares.id, data.shareId)); + + revalidate(); + revalidatePath(`/pagadores/${existing.pagadorId}`); + + return { success: true, message: "Compartilhamento removido." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function regeneratePagadorShareCodeAction( + input: ShareCodeRegenerateInput +): Promise<{ success: true; message: string; code: string } | ActionResult> { + try { + const user = await getUser(); + const data = shareCodeRegenerateSchema.parse(input); + + const existing = await db.query.pagadores.findFirst({ + columns: { id: true, userId: true }, + where: and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Pagador não encontrado." }; + } + + let attempts = 0; + while (attempts < 5) { + const newCode = generateShareCode(); + try { + await db + .update(pagadores) + .set({ shareCode: newCode }) + .where(and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id))); + + revalidate(); + revalidatePath(`/pagadores/${data.pagadorId}`); + return { + success: true, + message: "Código atualizado com sucesso.", + code: newCode, + }; + } catch (error) { + if ( + error instanceof Error && + "constraint" in error && + // @ts-expect-error constraint is present in postgres errors + error.constraint === "pagadores_share_code_key" + ) { + attempts += 1; + continue; + } + throw error; + } + } + + return { + success: false, + error: "Não foi possível gerar um código único. Tente novamente.", + }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/pagadores/layout.tsx b/app/(dashboard)/pagadores/layout.tsx new file mode 100644 index 0000000..619824b --- /dev/null +++ b/app/(dashboard)/pagadores/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiGroupLine } from "@remixicon/react"; + +export const metadata = { + title: "Pagadores | OpenSheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Pagadores" + subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/pagadores/loading.tsx b/app/(dashboard)/pagadores/loading.tsx new file mode 100644 index 0000000..85fa4b5 --- /dev/null +++ b/app/(dashboard)/pagadores/loading.tsx @@ -0,0 +1,57 @@ +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 ( +
+
+ {/* Input de código de compartilhamento */} +
+ +
+ + +
+
+ + {/* Grid de cards de pagadores */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Avatar + Nome + Badge */} +
+ +
+ + +
+ {i === 0 && ( + + )} +
+ + {/* Email */} + + + {/* Status */} +
+ + +
+ + {/* Botões de ação */} +
+ + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/pagadores/page.tsx b/app/(dashboard)/pagadores/page.tsx new file mode 100644 index 0000000..7ff25b9 --- /dev/null +++ b/app/(dashboard)/pagadores/page.tsx @@ -0,0 +1,86 @@ +import { PagadoresPage } from "@/components/pagadores/pagadores-page"; +import type { PagadorStatus } from "@/lib/pagadores/constants"; +import { + PAGADOR_STATUS_OPTIONS, + DEFAULT_PAGADOR_AVATAR, + PAGADOR_ROLE_ADMIN, +} from "@/lib/pagadores/constants"; +import { getUserId } from "@/lib/auth/server"; +import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; + +const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatares"); +const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]); + +async function loadAvatarOptions() { + try { + const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true }); + + const items = files + .filter((file) => file.isFile()) + .map((file) => file.name) + .filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase())) + .sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); + + if (items.length === 0) { + items.push(DEFAULT_PAGADOR_AVATAR); + } + + return Array.from(new Set(items)); + } catch { + return [DEFAULT_PAGADOR_AVATAR]; + } +} + +const resolveStatus = (status: string | null): PagadorStatus => { + const normalized = status?.trim() ?? ""; + const found = PAGADOR_STATUS_OPTIONS.find( + (option) => option.toLowerCase() === normalized.toLowerCase() + ); + return found ?? PAGADOR_STATUS_OPTIONS[0]; +}; + +export default async function Page() { + const userId = await getUserId(); + + const [pagadorRows, avatarOptions] = await Promise.all([ + fetchPagadoresWithAccess(userId), + loadAvatarOptions(), + ]); + + const pagadoresData = pagadorRows + .map((pagador) => ({ + id: pagador.id, + name: pagador.name, + email: pagador.email, + avatarUrl: pagador.avatarUrl, + status: resolveStatus(pagador.status), + note: pagador.note, + role: pagador.role, + isAutoSend: pagador.isAutoSend ?? false, + createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(), + canEdit: pagador.canEdit, + sharedByName: pagador.sharedByName ?? null, + sharedByEmail: pagador.sharedByEmail ?? null, + shareId: pagador.shareId ?? null, + shareCode: pagador.canEdit ? pagador.shareCode ?? null : null, + })) + .sort((a, b) => { + // Admin sempre primeiro + if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) { + return -1; + } + if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) { + return 1; + } + // Se ambos são admin ou ambos não são, mantém ordem original + return 0; + }); + + return ( +
+ +
+ ); +} diff --git a/app/(landing-page)/page.tsx b/app/(landing-page)/page.tsx new file mode 100644 index 0000000..5c8d9de --- /dev/null +++ b/app/(landing-page)/page.tsx @@ -0,0 +1,534 @@ +import { AnimatedThemeToggler } from "@/components/animated-theme-toggler"; +import { Logo } from "@/components/logo"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { getOptionalUserSession } from "@/lib/auth/server"; +import { + RiArrowRightSLine, + RiBankCardLine, + RiBarChartBoxLine, + RiCalendarLine, + RiDeviceLine, + RiEyeOffLine, + RiLineChartLine, + RiLockLine, + RiMoneyDollarCircleLine, + RiNotificationLine, + RiPieChartLine, + RiShieldCheckLine, + RiTimeLine, + RiWalletLine, +} from "@remixicon/react"; +import Link from "next/link"; + +export default async function Page() { + const session = await getOptionalUserSession(); + + return ( +
+ {/* Navigation */} +
+
+
+ +
+ +
+
+ + {/* Hero Section */} +
+
+
+ + + Controle Financeiro Inteligente + + +

+ Gerencie suas finanças + com simplicidade +

+ +

+ Organize seus gastos, acompanhe receitas, gerencie cartões de + crédito e tome decisões financeiras mais inteligentes. Tudo em um + só lugar. +

+ +
+ + + + + + +
+ +
+
+ + Dados Seguros +
+
+ + Modo Privacidade +
+
+ + 100% Responsivo +
+
+
+
+
+ + {/* Features Section */} +
+
+
+
+ + Funcionalidades + +

+ Tudo que você precisa para gerenciar suas finanças +

+

+ Ferramentas poderosas e intuitivas para controle financeiro + completo +

+
+ +
+ + +
+
+ +
+
+

+ Lançamentos +

+

+ Registre receitas e despesas com categorização + automática e controle detalhado de pagadores e contas. +

+
+
+
+
+ + + +
+
+ +
+
+

+ Cartões de Crédito +

+

+ Gerencie múltiplos cartões, acompanhe faturas, limites e + nunca perca o controle dos gastos. +

+
+
+
+
+ + + +
+
+ +
+
+

Categorias

+

+ Organize suas transações em categorias personalizadas e + visualize onde seu dinheiro está indo. +

+
+
+
+
+ + + +
+
+ +
+
+

Orçamentos

+

+ Defina limites de gastos por categoria e receba alertas + para manter suas finanças no caminho certo. +

+
+
+
+
+ + + +
+
+ +
+
+

Insights

+

+ Análise detalhada de padrões de gastos com gráficos e + relatórios para decisões mais inteligentes. +

+
+
+
+
+ + + +
+
+ +
+
+

Calendário

+

+ Visualize suas transações em calendário mensal e nunca + perca prazos importantes. +

+
+
+
+
+
+
+
+
+ + {/* Benefits Section */} +
+
+
+
+
+ + Vantagens + +

+ Controle financeiro descomplicado +

+
+
+
+ +
+
+

+ Segurança em Primeiro Lugar +

+

+ Seus dados financeiros são criptografados e armazenados + com os mais altos padrões de segurança. +

+
+
+ +
+
+ +
+
+

Economize Tempo

+

+ Interface intuitiva que permite registrar transações em + segundos e acompanhar tudo de forma visual. +

+
+
+ +
+
+ +
+
+

+ Alertas Inteligentes +

+

+ Receba notificações sobre vencimentos, limites de + orçamento e padrões incomuns de gastos. +

+
+
+ +
+
+ +
+
+

Modo Privacidade

+

+ Oculte valores sensíveis com um clique para visualizar + suas finanças em qualquer lugar com discrição. +

+
+
+
+
+ +
+ + +
+ +
+

+ Visualização Clara +

+

+ Gráficos interativos e dashboards personalizáveis + mostram sua situação financeira de forma clara e + objetiva. +

+
+
+
+
+ + + +
+ +
+

+ Acesso em Qualquer Lugar +

+

+ Design responsivo que funciona perfeitamente em + desktop, tablet e smartphone. Suas finanças sempre à + mão. +

+
+
+
+
+ + + +
+ +
+

+ Privacidade Garantida +

+

+ Seus dados são seus. Sem compartilhamento com + terceiros, sem anúncios, sem surpresas. +

+
+
+
+
+
+
+
+
+
+ + {/* CTA Section */} +
+
+
+

+ Pronto para transformar suas finanças? +

+

+ Comece agora mesmo a organizar seu dinheiro de forma inteligente. + É grátis e leva menos de um minuto. +

+
+ + + + + + +
+
+
+
+ + {/* Footer */} +
+
+
+
+ +

+ Gerencie suas finanças pessoais com simplicidade e segurança. +

+
+ +
+

Produto

+
    +
  • + + Funcionalidades + +
  • +
  • + + Preços + +
  • +
  • + + Segurança + +
  • +
+
+ +
+

Recursos

+
    +
  • + + Blog + +
  • +
  • + + Ajuda + +
  • +
  • + + Tutoriais + +
  • +
+
+ +
+

Legal

+
    +
  • + + Privacidade + +
  • +
  • + + Termos de Uso + +
  • +
  • + + Cookies + +
  • +
+
+
+ +
+

+ © {new Date().getFullYear()} OpenSheets. Todos os direitos + reservados. +

+
+ + Seus dados são protegidos e criptografados +
+
+
+
+
+ ); +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..b2c1b51 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth/config"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..ccdf9d5 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; + +/** + * Health check endpoint para Docker e monitoring + * GET /api/health + * + * Retorna status 200 se a aplicação está saudável + * Verifica conexão com banco de dados + */ +export async function GET() { + try { + // Tenta fazer uma query simples no banco para verificar conexão + // Isso garante que o app está conectado ao banco antes de considerar "healthy" + await db.execute("SELECT 1"); + + return NextResponse.json( + { + status: "ok", + timestamp: new Date().toISOString(), + service: "opensheets-app", + }, + { status: 200 } + ); + } catch (error) { + // Se houver erro na conexão com banco, retorna status 503 (Service Unavailable) + console.error("Health check failed:", error); + + return NextResponse.json( + { + status: "error", + timestamp: new Date().toISOString(), + service: "opensheets-app", + error: error instanceof Error ? error.message : "Database connection failed", + }, + { status: 503 } + ); + } +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..acbf006 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { RiErrorWarningFill } from "@remixicon/react"; +import Link from "next/link"; +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+ + + + + + Algo deu errado + + Ocorreu um problema inesperado. Por favor, tente novamente ou volte + para o dashboard. + + + +
+ + +
+
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b37907614beb95203beeef4bb383e213e3abdda3 GIT binary patch literal 11630 zcmaiZbx>YUllA4n-QC>@?(VJ+E`i|g!99ck!QCY|!QI^w+?@x9;F935+4r~g)o$&# z_1>DQneLvcd;d7yedb&MfCAtE3=H7!Ljj0_0Kn?+ih$rB+Xxx}ivId2DE_gdVE)c< z0Km@vkG=4B9fbq{;^P0RB60KqCqOMtzzi<3I+!4f#bLDuK%9dW00cA!iQTooc2V#<8eY`Q51MOyhI<&%=}^VRMtEoGh&?4-Ad^N1*$)R# zlU}kVcW1w!M`{N^g?XyMcaS$+@kjfEf=X;t7)9CU4}k>1fC&1eUu>+t6GY)LAdJiWCbk5A$zCCUgbW!x@mVG4tu=Vi~1tAxHU;X z9<1}A)9dPe{<;xPEv|xmJ2T3gNw*02O5?zy-1Z6D^gb%K?;dTBonuR=at<^kI+>a2 z1ZK@-Gb^`34%|a=PUoth!nHgnRfi=)H3R27z*Nhl!fDn&$74pG!9JV-#mg{ZOt@4N z0=#lEYFu}bk@u{rEvvczpo?Gm#K4qot)68>!`w@YI(Ncs_s63Chw@Jk)+hcphddc# zMmq;T(H0%NPSAoS=oGzJS_@VSPADdXwX<4orgavBAFFW|c+YrYzrrSpo^6pYkjE)t zgFoYy%Cr4>-VVy_PJ%rr-#RZ@sqbgl3X&U-F znNk94;Jls(h>==*%o8@@%&<@~q7?Nr*lA#}N*REwjT%uDLsvT-w1Om9L-SV~m64Fj zDRdPeR4if72`OBY{5J;^e)N+8&Z7_83g$k$j$7`NA z&zEvG(!d#wcptnl73^S0F$ViS8WaZ924UdqBDww)Y*sXx|lJi2dbAd>zHjQpW?a$mo5@O%f9Z zi%M_eLOZx_RNq>p|11RIoGdbb3}X~G1WttkRS`-D`I8u0`S%NwSkv!YnhUmts<2cz z?#^L@O`^!8n5fhubaeRZ!9hqJh`nkfBjIe=*kPC?j$G_{SC`Lz5+O&FP1?9Fe@CL+ zvtBcfLuxYx93K;DRMgfUV=Cd7gM6`)0=|lJ6y7X7Dd2qrnF$-trQOU>L`tFJXljUw z;!@1dSo9k+zh(mvYsp}kxduwpFGb>NH)<7O=nGw=ubm2&H$|+KI`SECIEC(G%s%~B zyrf~SDp!U??TLAXjq>~(PLQD$D-yfK6qDsi5|>LZ($ASDs~E{m7?eD;F*+puE~Oze zqR1IhOqBqR44F%J>1VO5GH`&Lo*N4w1~ly0J%g6Hg!s8ZAUyNFPs zVEE}UyZv{BYPR+I%u6r~`nHJ~oF=%>Sxmguv@#o6Rp&W_9T8#8>HB?39`$U7##x4& zHYC9zcKWo3O%6+M+Rw0S@4)R_aIMq-Cbua5#wq@lTkgKTw*Ua6@lS4@>2BGRX#dTv z9HEA+74k9ONEK!27|BQEIUU$K`IJy}ohNcI#uhUblL1D-p3G5L_dQivez)Ege(2+L zVOd;LF;zkGV&g(lU$esM3kR2w`+i45!^-!LjF@JeK4_`_@wPkvL!m8^!)gNZl`>ak zmCYZJH|YHGAjvGi6j8jTEBgK@3q>5bq|P^5ibutuYlS00+)LJj{ca6_D!G2)tw#s5 zzTo8&xT_<3j(>vVOzl11$9Qjai;B@@0<=d%H%L=Uy}n1~`G~sa?fOFoV#@&aiAbF= zkV=*YroJlG6o&(W;D*pf9JqWh8#1;-4P&MGGh??GLwI0_-E z#xj-|ovhh)P`LV&*iYTEqIb=c3hju8VpW8={>TnVB+~*Y&#nf$je;6dgHh`IVbBE2s{|44+==r$9HZvWJtQj;B|*sej9;iMg!|@ zw)-TAm!M-xhe=LJ-CZ0+qh~~I6_YGdNcxnT5d56ITx@;}ffWeZv3()~cF{J)P73x0 zSLI@1j_%AM z2g>1D&;uSUtNj+^;vOGz2){VgqI)p)ZTKMS1dB%UF25Tc&c$4_VZe0L1$4}3ViWrp zp^#H#2W;7FNUBs*YZDO?BGq3MK~v7>Tnfdh;(WO;*o1oykp=`j_)XuOTNbB;aRU?y zL~``et~QE$ZzKME0zc77XX}D=g<~>?1bMA!qhf7lr!q?*?6MOiIp~)QtY^%+6+O!TlwLmh-}pFFX>LB0U#xS46RsPgcCODh(ocERsY*Zp1{25D(D{;>#Y1$%s^`Yfi5KqH2Eo0Vctqh4)EA?J`rqE zR_7jXkCe{B-+rlK_Ig8smGFqO7+zQ zO!{UnSA%KY847XTD#&>P>}tosEsmABg1b^JAcpp4AXC;PfGPqV5B4|SoxvoYTF>Ii zXd!~GBiWjK;Pq=6IfuZ5>>8 zR6Zm?;WJ~JJhXkKl-t=`5H#TrBqd}VKPtKKv$pp>R7RD`=;6y7RGyQJA{DsU9y3?E zzs$B~DD6+d;P)>4db2-@kRo-^fGk=PRBp=u?&?J{cu5ufHE5Moqv(a*Os+EgYUtBa z?J=o<|4WCn&DL}xX+yaTQH_(ktHt8TxZ4JICvkEm$JH#XZ!`iynB$yXwph zN?le?>VH#-{*z_0|Em(|f*vCNGSBRPD$%~ap#zR3&g*N&%8H+DL1Pm>(_M6Mx;z7> z!fzQ&)P#yo22p}t)@=!B^v}{JD8qyB+}-(fve@Rz+#DRT+#1zXvfK<5pzV?nNkf~C zjg9lkeZIqS)eg3e<5O7A0o%t~uBW%RhslS@v-dNbvn(0|h55rW5;4)CZ{=SM;3@1$ z0Vh0QH3m{m6{Qutxi%{%^QsLL87-Cs3E+LISN8$hsW8A5(2~sH{7dy7NqZH5RX?sP8!L}D7AAeKq<^6_DNdjnyW7;HeHj7 z41BqGh0{!?P#}SiQeg-D(9T^t5FZ4AN?SZtYiVu?+%2ByN zl1c{SH8Q>Yq4TA_;NcY2Y+w$Zp%P(z9{fV-gH73+VTlCA&>Z2i6TH|4Z{I&=q|1fn z#RpsKqj4`dr4ue`QKzBwK_x5J8bAC>-Wp@;H_`pkIs@7tLU}`REqMub+;}(Pm{)39 zH`|Yc$p{Rpiljdj$G?_)n*}1odBSOwB9f4?*F8mrwDS7I+ZU@x9MzR*%h}CaSuT1V z>8+LQn)Za;smATZnIUW3sdLm}j+zhb31Fv}fTpI9ciVg@|FFRFaU6}UP@Z->+$+|- z0vKYbkqZ-0Eakcxink8za^VXqP~RO;Ad<5m;&l=TAYgmN*bw+N4~I6^ajR34$&fq8zAz+K;prKh!UC~?Hb(hcbTUD2YnTW?m(nT`A z2?u%f<`%ml^uA!LO!&*jpM2IrNNy^ind)xZje&9JXCj&U)dp0V33}crIwqhB5_(jY z2n?!>Ek<3{Yi1@6KKXNQMVh05OIUedyKRj?iGc&}B$6R-q$VQDHA?n-)k_md-tyFj z6nqT$F0RdFg6FxFgimHOS66Fd`2)}Jo3dSi(ucsDsQo!QFY98en+kT%?=6Gcw;nJY zzqUQ6C`x4Z`Xhuq=1)M-8LpF>colef%xO)bOc*+_oT-D`m2lndHl|C(EFTQ6l=l#W z@ia`{tCwC=YI*iWxlcJOIOTP(8SyNW96DZCGA`-Kbx_4HdfN2 z02Lqq+x7>Q;7rN-mfMnC?okc{jz9`W}jgjBUyrJ_!-a@j!O9UB919w^6iDy+pkOs%6$W68IJNgXTin zO-x9(L&2&f2!-+cd(U1SJFPX^BjVMZy13#6d}YUlcI}R~gZOV<-316P^h&ZTE4Wfq zU-Rb*qltmua^&7B4P2q~uh#{s@OQ>Tj82{6W&H|E(gQinA6i>vtOxF)G-k!6=5QEa z4z3BTq{}6JOVwAi8RWvhI8z9#%U45#M&MMk71NDIp9i%S$nrxD7A!H>d5m`@2G3;a zv05tEU8!PX;IW?dSrexmt2@Q1?9J!Y6tdM;yDwt=pu zFnn<|LpB>H6b&(KkP!V{=)zjmN(68ZN4gmM9Fcr}HHdL7b=4pN2c@9Xo)3_jfPv*e zmI0-<%LMn!nr@+82x=XezGDm+A7rczkf~{BJL?&zAl~z zoXnpCSG3ddx&m3kb43RDr>$#2(j1z@O$QOya#zRUuJ-NPX*xX~{E^?n@Te+G)p7{x zYab42aC{ASw5j=$i(f|%d7URGLVL9uq>QG^y7FUGQ8M&;ywZ`uW{-P%oV0k0<@cR_ zNLyLeYa(B_;8fo8VTAi$`fHxoB*TQRtKe%ypJ;d1(?q4r@i;7}^~Svn6Lirred6xb zdMiQ=g3FO&!$mWK_=&M5iKAVhKB`LS{N zO>!pI^WQWz@xO8Le>JrVTh~nhKzR7Cre3kd`e*S%#7e&xB1E1E*sV zZIul_TNz(L@d3|*A64)mFulq=Hh$@xhHXE8yQ%qwAaMyG%8}jI zu&XWXIo!8x8;8H*7;eVqtf=bn1vsfng2D#|knv-i~!MqIi(=%jY&$NMrS3zW-hf48m}e0pPH*VVooEw&&zjb0p(b}Mn`s32h&~06H((h z_Cd?!!=%=C;TXB4?_=`*zEW`L-I?o1@PT;hXsZC#ydEwjzroZ_r?(HLqa>+`Pw zyr)LGvCo}U!Ho31--#i}#9Yf>0caP$xO`AzQ9hWFP2jh@PwJaHWb$?wT}N3^#X>1; z4Lqq0&<;AV0oW-McMRG-BY$|WyS(7Mb0xBI|8UNBdc?zV!lYWN;9B>I$B9AS-;O`b z4b_!58y!)GIxrwE#JH06&z!HQ@jEd+#YV|BUsQi9sn1*3!A()`no8c8WSO;m=cQO> zK9`X!2(b-~L(@N+LhweUQxb$fYmWtDs!MVF+|xq3q&~9fDxn7B124^kBcC16+|QV_ zJ;&ADx`-M4k5^F^Eo25)NgCu4lNfDLn8MFdV_4C?qQ-gAFXAIF_UC9%p z08vp3E=0P>mNPs9q;ToghZ>W#q1AL93haa=bO$DaR@+`l+K{{^EgNikSGP3LEMEli zNt|rrdyYglu5)RUTu`_n91DsSEj<|U3=*<%(eDgWldb4b2h%pUu zJ~gIl{;LopYH~{QCoXC7SH@u_Rh7M%UG0 z*xWw9B5C%9pNwfoltMyr2J)>E>8Mo?hhHr!(pszVNto6P$jS)A!+G`h;6VOhta1CENbjuY1cKT}!<-d6BFlaQG~h z4%a=?mIhFv=Zvb_ApeVEpRX!@=^h}sfGej`N=vR|RB$+Mp^QeKoA>Ha0b|-rohcIC z*mmb&cLK@BKUwQgPO$oqP}7bIa6A42y{5blxxacEspjh0y0ETc5bB)MXqQjprhL84 zemMGAXUNBmp5u3PxE`jml;qSlZL&1iXJjo;Fo3)}H=svu8vV9?^WE{K~A0ft*9FD!|z`%N>FxY|}SPC4)==yC9en6DR< z-=aY_HqDivD9{8$CZyd*8k9FMK>ve8Vn_H`#$D^?MwzkqwGNBk2%J`g*u^;{LUK{w zr2-?Zq7*fzkV4-e#=pH&fNd$t5qN6>8~4)D-$B%bk%j~MGKoL>*e#+nSY9~QTg*CP z`KzQ1=cr|1*IrR>*UJ2{=spx1@@h^C{zgYfD*$hjJF-Q$-aZ9AJpwBQ3DG^cN<+jC zP!ScPm|HL6donbaS$~0Podm zUE|hV$WLcg?b?cvrbbi%HzYm<)FX5PX_%V0P9pKa_UCkOBE6!5%@sX_jLo6>o`u^F zuwd~UqPSe>MXLC_Dkv?@a1iJ4lk)3)z?c&zG^p%!umUpka55Q~Plb#IbKV%PuW$l% z40DdT9~SiUyz&g}l%S;7FNK)}raXJP)6jHAenNG-7Y!0x8_ZVjbFS~P`oaa_28_geK|VD2`!oCgL&NbaqMt< z%|T34)?J}dyzv6tXJ!TB6_RUg)ktK^RGNb45q82x`8+TZ# zmy>oscEy=X>g?}qr17{N^_%r|?5pT#K6lC_`uS$+kg=h=x7q9vX~Y4+6~F#_?^SHF z-awn){v_+{RGn1cz?teTl!PbELh15rczD+AJam_89GCYT^0n$auobVPL#vd9IbxZ! zDw?1WUmu*eq{r&!GRpwfT5U;tIuOn5ENT+Bd85*Ur62fDl}9L;8H<$de-F1}{My-T zvEzu-xCy${;(x=Kg8#;Y|HYUFM9qEz09Mg|36jP>4wef(Z#f-ZchADP3uIH1-d4+2 z1V0Sq5fvyK#S8Jm#VadlV_>3~?x|HWo4Je2v=J1DEN!V%SPGG}=Ncn5X(^2n$_hfb zPceoqJ`<*{@J%`OyliT{bo<5maO4jC>A2W9^b1NH`Li5&vF;vt_|$#lD91`}^gV&H zsa8-V7$rwWHaMA@TKq=>CdFTYCEm>fMqYpuR764ug@shyAJ_WVUp@^ii8-PDjW8VL zqOL-|O(4X(3{Hf61sLW2&(oT*|1G?2ibp*upE-Qm`<)K>W;=mgtR{)Haodz>;X1L! z{nj(&Eutw6D5$2h7bt`i?g~#yPNMacR{vZ+=?ls9Ai_?<%PRj&y^y2s;6kaXn zP#0t#E)P`jBPRxUj;n&=FV`0u?0cl)zug^MF+yH>=r(qKY^e;MBZsD5^g?c>W}I~Q=L{qr@Y6#oyN3d;ylzmF(ht}k zmJ*Z4B7;3r%EI(a$a^CNrxE(^ep!tR%3&a$FonbB@^nk>_SUt_QvX;#`|Ac#aDtH0 z;ykZd)PXOqglV5_>8(m(YH@|#-848zxB?(9JQ!i@NdTgBAILZ7=*yzy(GeItM>Ilg z03JUTgY_V6J(7_tSSm)Heq7RZ15Go`0qL;vC{X-4IOT)NUbrHDx07S`1xCJx7x_RgOebCVRL5#3#a(gz|e`9h`w>c}al z@NT@6VH*niu-CgR-F??NMRc)c?SZ(vg}M$BK?C2yDq&pLZi-U%THK1haj#=(cAP

-|LL-EybN8Bs6W})h74Uqb?3m@y`+I?>Y$)*4Vvh#rE zWPH3Ub~k^y{3>W7bRG?6kfs))rYiHM)n18P@6~ciR%mNMdx3xPLGI}ij<~BC-!6RE z4m)Fn^)|JK%_Uas^Ns@4KAkGtb6-f<*4G(vp#F8V|fG zQv9kkKEI2_3{H~NwW~4TZZ9S~Xh)(aGTW;@jksxdiVf%K^6B)-h|VJ^+kNV=^S+Xo zD*8-(JTtbKZ#p7$ZvI>R15|0f#&~OM79;Cqp8f70PoL-E{AkfBEZlw>Iz}c@7oVPJ zdpMEj8{&oLU%|18fa>IIVSh?A$;{Gljgx2^?Y5-3OkDVuamql zae#=+xcOTg-gq8%T&d*N@Wk@=^8>}|H%~4SZN17^87BtbTUhu#V#y=nH>X$9xMQlC zNre5qZ2@>}#(SoRv3~?IgcJdyk3DVkLJD-7djg2u*d)a+zk{PI;f#d6$|x(guUs3u^B@khjIbWBj-f2#88vR4dz#Km7}Qsjl*me|0aA#g9t`$%>ESUnGh{^w~!DwyJAHOC7lLG zbYXD%!zVz{MEje)4-GY&0mSr0*bqfY7TVkun!53ztv0h`6%CdDk@VG+AudbDo4?Wq z@ga60Q*&k#gM{;ijNBk;q#=U1pbEoXRdx;06s6o!r1^{M7+-du)BfZ(jK;;aec&K& zaRaz)B#x*5)U12TFSWJR+}NZj-jWea#jCayJ+@Tj3?Y3%3v8{V`}Ac_d;KHG1#84V z<|n?Op0)R8y$jLCx9#I|&c@1%gq-R;#)!)>pz% zw6nw-T7UnKdDzkJm5x}!D_m(TF11#%tc9%DZ`J@&m`5fq%kv|awE@|jG)C~aCFJmP z@us(^(Pu^rmA^j|*v1s}Iu}eP|6l;Ut0u%foLd>(r&-&##7C#1O1v26?KZBH_%mFzn{zk}&Nn^iiL z>6t^BCBh7J=VrfU&@Jqv@~pOBW{Z9P*>?Z*Lp0DWKO`@9e*ewDlj=(hV3qKIv4rN! zpr@F~SU;3UVkM%Y9vSe3U?*_L)EMhS9-*!2T3OugZ}IlLM#ZFIxnPDV$Fik9TI8H* z7euW)PHA>av+S%|E&cnqwGq&m(@uYnZ+7(LGc;>tA&o5a3sjvVK&Q?b>F(14h7fDsn7e!eOwq?At%0hR%I?nVX3-NU$5X#~w*p^$~7J5?DDM_i4dh{JnxT zPXQ&5U{FifX*=6KD~>QR6rHBedQgdGp@tv zouA=yJ}=;7;s+zs0E5zFLq5nio$6m_sO34+BeG&F5bon{sZdY_4KA}{c`n!Gq2Ks4 z(<~NA&AWy9hB{V`={gvl%J##e%>&4Pu|?H|WQWTLAT#^2x+3L>EhR84?SzJl^17=l zV#$;+%$KZ;jn3 z7Uzt5;9~1kusO3J?p{WuaONyH4#Uy?5=MQCq)w}O_`3~>IhuFzc2yJKds7%*N(R~8 zNa-V@=eK1{#EXetxv_6ztA+z|dBjPXa-!T%m^T751sj`nMbl&LrlW;&-{Yx%0Q}$u zf^zPnRr+AOoVLtQ>E=psSC4IIY$sktx6ETS>KcRuk^X)D#BmqSUj}!%=&Fvb3~747 z>~)p)-xNIK*?U@62fB#;FklLX(!p3TSWw!_TGv;R_)6X$?@?uPw-Z=s3T2eJzW%n? z8v`kmWgW;r5p3}E(J;G(y*sPBI*yKR4UD9(ps@Xau}_PcBj;XLp_0 z`Gk7<1qNCm{OKLjPHKuka-rg3(N~cx7(pXsLSSmo0V)yrv{w{o+x(dEi>i8pxyiYJR9EN?*51&KhDFg{TvPX3i@$-@f zOVERS1EGiyxzcKHuzBZR9Rehf6w4ukq~uC8P|8E>B4J)RTD{J!Q{gYb^hPa} zX2bWt_i|PU^&#$XWrh$h9Sw>VpG(X0q`rW^4Z}OwbOnsGDsgx4b1V-mhn-KIb}5k> zeIn2qbF%bE>VMivc9rewW~D{c7A4;sx3+ZPaxq-!jdskYYfBgG@r*#>8q%}692TB1 zB$kC2^r1~)V7KM_?cS&9yoOOVtK$W*-&9Zpb!cBMa0MAezo0jV>b9bCc5C;XwfmC`mMn@ zNv(VYC4XWh;CW(7iC=xjZNu0O9Ly33-CDSX*|l{jE&DzT|4m(c_Z)wO=Hpb240_9U zR|eh3B0R|4fF%mK{TC#Mmdg{pZ}M!Mey4Tw$JoSCi3t71{2PDlxQ-ugdKWnZYb)7a z)_t}h^zTk3a>wX76shJsZ^=!nD_wLsZ)cC6VcSgP0|I|rwCP{SY$<@Jv&;TJgtk0t12 zZRL$+4vhHw2f7pTkeVPm4_=4WXkX@#^4u<0>B53tQIJFe^JCvRuqmmCSA2jfp+9$& zB6BjyfF=%T@w<{*liF<8+JGD}2yB44yx&T&x;wrxwa2x&pOwKQOsyk{ZEp4c{osM!1Db0-8pg|O z4e~qmG}qc6b~za_Q}J)iH9D8e-RC#v)-QC^SSDP>**x+QPTK+8+Zo-H3%s zLON}(d#-$8y+nDUf&v`MsH~l&+ol7C`aWnQi(m! zBR>Dk!hJAt3d*9y9K6@;O*I$oZY;(+e`H)H7EC0Qvm?qQWHX=Rc#kr|l|XjT=(_dO zQaETiO{ogI;hu@I4hT(BK+iuqngQ-Q!Tlq7bPQf=fPNsIrVbTviL7->)D( z@7ilKmT?z)F(t`kDo0E$HEUnK_xrW0-8v_MLHgb&^nSi1v9(ShvX9Y;{m@e9{hAKy z34lfONc&9Y*g1&N`^d3M(~&=-9xBiEL3J;m?}X7>J9-2^H@Y*~I42(I)!eVW1 zXd@YH&RLhR=NM#(rIatVi$sLhTVA9C{#dbUAzq%Y7lv-ft{ z2{XLfx^61ae}O8^In!WM@4JHY&p$7+h=hTNdb2!-s^ffJ3si(kYCr@}8qo)s_ zui>|Hy7=wu?8$c>h8iw{CYDxGwWJtHEpF`e{Yc5&v%>cWg$hZ-TJ2# zWBc2sp&Hw6lnrofCZ@Y_i~IYtmhSHK3(#I$Icjl=#HsvT{$L#b_Q3M!o^|!Xx6_)) ziGC_P#R3|Y@3ylXbsRFfn5H|>;lDqg62=cWN(rFgI$o`9J&Y5)dIb?2%{Y!;M8Hk{ zYTE1aF|+7k3(Q4Q7Rrpw9j~SZC@r-myeE6=w-OW!tRs5Xr1hADXl1 z8Y>amtCwtUAAFxdBdW>9hDl$RoWw`mK!&i?p|+>4_(U2@e_U-mXt9Ej(lx!#*syyP rdcRWw39NxX5aZ2${ZA3+f5{C%A_F6%BPKEbUEKN)#ipWP literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..8ed9b70 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,209 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --spacing-custom-height-1: 28rem; +} + +:root { + --background: oklch(95.657% 0.00898 78.134); + --foreground: oklch(0.1448 0 0); + --card: oklch(98.531% 0.00274 84.298); + --card-foreground: oklch(0.1448 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.1448 0 0); + --primary: oklch(63.198% 0.16941 37.263); + --primary-foreground: oklch(0.9851 0 0); + --secondary: oklch(0.9702 0 0); + --secondary-foreground: oklch(0.2046 0 0); + --muted: var(--background); + --muted-foreground: oklch(0.5555 0 0); + --accent: oklch(0.9702 0 0); + --accent-foreground: oklch(0.2046 0 0); + --destructive: oklch(0.583 0.2387 28.4765); + --destructive-foreground: oklch(1 0 0); + --border: oklch(89.814% 0.00805 114.524); + --input: oklch(70.84% 0.00279 106.916); + --ring: oklch(76.109% 0.15119 44.68); + --chart-1: oklch(70.734% 0.16977 153.383); + --chart-2: oklch(62.464% 0.20395 25.32); + --chart-3: oklch(58.831% 0.22222 298.916); + --chart-4: oklch(0.4893 0.2202 264.0405); + --chart-5: oklch(0.421 0.1792 266.0094); + --sidebar: oklch(91.118% 0.01317 82.34); + --sidebar-foreground: oklch(0.1448 0 0); + --sidebar-primary: oklch(0.2046 0 0); + --sidebar-primary-foreground: oklch(0.9851 0 0); + --sidebar-accent: oklch(93.199% 0.00336 67.072); + --sidebar-accent-foreground: oklch(0.2046 0 0); + --sidebar-border: var(--primary); + --sidebar-ring: oklch(0.709 0 0); + --radius: 0.8rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), + 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), + 0 2px 4px -1px hsl(0 0% 0% / 0.1); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), + 0 4px 6px -1px hsl(0 0% 0% / 0.1); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), + 0 8px 10px -1px hsl(0 0% 0% / 0.1); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; + --month-picker: oklch(89.296% 0.0234 143.556); + --month-picker-foreground: oklch(28% 0.035 143.556); + --dark: oklch(27.171% 0.00927 294.877); + --dark-foreground: oklch(91.3% 0.00281 84.324); + --welcome-banner: var(--primary); + --welcome-banner-foreground: oklch(98% 0.005 35.01); +} + +.dark { + --background: oklch(18.5% 0.008 67.284); + --foreground: oklch(96.5% 0.002 67.284); + --card: oklch(22.8% 0.009 67.284); + --card-foreground: oklch(96.5% 0.002 67.284); + --popover: oklch(24.5% 0.01 67.284); + --popover-foreground: oklch(96.5% 0.002 67.284); + --primary: oklch(63.198% 0.16941 37.263); + --primary-foreground: oklch(98% 0.001 67.284); + --secondary: oklch(26.5% 0.008 67.284); + --secondary-foreground: oklch(96.5% 0.002 67.284); + --muted: oklch(25.2% 0.008 67.284); + --muted-foreground: oklch(68% 0.004 67.284); + --accent: oklch(30.5% 0.012 67.284); + --accent-foreground: oklch(96.5% 0.002 67.284); + --destructive: oklch(62.5% 0.218 28.4765); + --destructive-foreground: oklch(98% 0.001 67.284); + --border: oklch(32.5% 0.01 114.524); + --input: oklch(38.5% 0.012 106.916); + --ring: oklch(68% 0.135 35.01); + --chart-1: oklch(70.734% 0.16977 153.383); + --chart-2: oklch(62.464% 0.20395 25.32); + --chart-3: oklch(63.656% 0.19467 301.166); + --chart-4: oklch(60% 0.19 264.0405); + --chart-5: oklch(56% 0.16 266.0094); + --sidebar: oklch(20.2% 0.009 67.484); + --sidebar-foreground: oklch(96.5% 0.002 67.284); + --sidebar-primary: oklch(65.5% 0.148 35.01); + --sidebar-primary-foreground: oklch(98% 0.001 67.284); + --sidebar-accent: oklch(28.5% 0.011 67.072); + --sidebar-accent-foreground: oklch(96.5% 0.002 67.284); + --sidebar-border: oklch(30% 0.01 67.484); + --sidebar-ring: oklch(68% 0.135 35.01); + --radius: 0.8rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.15); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.2); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.25), + 0 1px 2px -1px hsl(0 0% 0% / 0.25); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.3), 0 1px 2px -1px hsl(0 0% 0% / 0.3); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.35), + 0 2px 4px -1px hsl(0 0% 0% / 0.35); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.4), + 0 4px 6px -1px hsl(0 0% 0% / 0.4); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.45), + 0 8px 10px -1px hsl(0 0% 0% / 0.45); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.5); + --tracking-normal: 0em; + --spacing: 0.25rem; + --month-picker: var(--card); + --month-picker-foreground: var(--foreground); + --dark: oklch(91.3% 0.00281 84.324); + --dark-foreground: oklch(23.649% 0.00484 67.469); + --welcome-banner: var(--card); + --welcome-banner-foreground: --dark; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); + --tracking-normal: var(--tracking-normal); + --spacing: var(--spacing); + --color-month-picker: var(--month-picker); + --color-month-picker-foreground: var(--month-picker-foreground); + --color-dark: var(--dark); + --color-dark-foreground: var(--dark-foreground); + --color-welcome-banner: var(--welcome-banner); + --color-welcome-banner-foreground: var(--welcome-banner-foreground); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } + + *::selection { + @apply bg-violet-400 text-foreground; + } + + .dark *::selection { + @apply bg-orange-700 text-foreground; + } + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + +@layer components { + .container { + @apply mx-auto px-4 lg:px-0; + } +} + +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f4d7dd1 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,40 @@ +import { PrivacyProvider } from "@/components/privacy-provider"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import { main_font } from "@/public/fonts/font_index"; +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "OpenSheets", + description: "Finanças pessoais descomplicadas.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + + {children} + + + + + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9bbd750 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { RiFileSearchLine } from "@remixicon/react"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +export default function NotFound() { + return ( +

+ + + + + + Página não encontrada + + A página que você está procurando não existe ou foi movida. + + + + + + +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..13a92f1 --- /dev/null +++ b/components.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "@remixicon/react", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@coss": "https://coss.com/ui/r/{name}.json", + "@magicui": "https://magicui.design/r/{name}.json", + "@react-bits": "https://reactbits.dev/r/{name}.json" + } +} diff --git a/components/ajustes/delete-account-form.tsx b/components/ajustes/delete-account-form.tsx new file mode 100644 index 0000000..6d87be7 --- /dev/null +++ b/components/ajustes/delete-account-form.tsx @@ -0,0 +1,143 @@ +"use client"; + +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"; +import { RiAlertLine } from "@remixicon/react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +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 ( + <> +
+
+ +
+

+ Remoção definitiva de conta +

+

+ Ao prosseguir, sua conta e todos os dados associados serão + excluídos de forma irreversível. +

+
+
+ +
    +
  • Lançamentos, anexos e notas
  • +
  • Contas, cartões, orçamentos e categorias
  • +
  • Pagadores (incluindo o pagador padrão)
  • +
  • Preferências e configurações
  • +
+ + +
+ + + { + if (isPending) e.preventDefault(); + }} + onPointerDownOutside={(e) => { + if (isPending) e.preventDefault(); + }} + > + + Você tem certeza? + + Essa ação não pode ser desfeita. Isso irá deletar permanentemente + sua conta e remover seus dados de nossos servidores. + + + +
+
+ + setConfirmation(e.target.value)} + disabled={isPending} + placeholder="DELETAR" + autoComplete="off" + /> +
+
+ + + + + +
+
+ + ); +} diff --git a/components/ajustes/update-email-form.tsx b/components/ajustes/update-email-form.tsx new file mode 100644 index 0000000..ed862f1 --- /dev/null +++ b/components/ajustes/update-email-form.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +type UpdateEmailFormProps = { + currentEmail: string; +}; + +export function UpdateEmailForm({ currentEmail }: UpdateEmailFormProps) { + const [isPending, startTransition] = useTransition(); + const [newEmail, setNewEmail] = useState(""); + const [confirmEmail, setConfirmEmail] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + startTransition(async () => { + const result = await updateEmailAction({ + newEmail, + confirmEmail, + }); + + if (result.success) { + toast.success(result.message); + setNewEmail(""); + setConfirmEmail(""); + } else { + toast.error(result.error); + } + }); + }; + + return ( +
+
+ + setNewEmail(e.target.value)} + disabled={isPending} + placeholder={currentEmail} + required + /> +
+ +
+ + setConfirmEmail(e.target.value)} + disabled={isPending} + placeholder="repita o e-mail" + required + /> +
+ + +
+ ); +} diff --git a/components/ajustes/update-name-form.tsx b/components/ajustes/update-name-form.tsx new file mode 100644 index 0000000..4fac3dd --- /dev/null +++ b/components/ajustes/update-name-form.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { updateNameAction } from "@/app/(dashboard)/ajustes/actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +type UpdateNameFormProps = { + currentName: string; +}; + +export function UpdateNameForm({ currentName }: UpdateNameFormProps) { + const [isPending, startTransition] = useTransition(); + + // Dividir o nome atual em primeiro nome e sobrenome + const nameParts = currentName.split(" "); + const initialFirstName = nameParts[0] || ""; + const initialLastName = nameParts.slice(1).join(" ") || ""; + + const [firstName, setFirstName] = useState(initialFirstName); + const [lastName, setLastName] = useState(initialLastName); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + startTransition(async () => { + const result = await updateNameAction({ + firstName, + lastName, + }); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.error); + } + }); + }; + + return ( +
+
+ + setFirstName(e.target.value)} + disabled={isPending} + required + /> +
+ +
+ + setLastName(e.target.value)} + disabled={isPending} + required + /> +
+ + +
+ ); +} diff --git a/components/ajustes/update-password-form.tsx b/components/ajustes/update-password-form.tsx new file mode 100644 index 0000000..0f3d361 --- /dev/null +++ b/components/ajustes/update-password-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RiEyeLine, RiEyeOffLine } from "@remixicon/react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +export function UpdatePasswordForm() { + const [isPending, startTransition] = useTransition(); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + startTransition(async () => { + const result = await updatePasswordAction({ + newPassword, + confirmPassword, + }); + + if (result.success) { + toast.success(result.message); + setNewPassword(""); + setConfirmPassword(""); + } else { + toast.error(result.error); + } + }); + }; + + return ( +
+
+ +
+ setNewPassword(e.target.value)} + disabled={isPending} + placeholder="Mínimo de 6 caracteres" + required + minLength={6} + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + disabled={isPending} + placeholder="Repita a senha" + required + minLength={6} + /> + +
+
+ + +
+ ); +} diff --git a/components/animated-theme-toggler.tsx b/components/animated-theme-toggler.tsx new file mode 100644 index 0000000..39a6da2 --- /dev/null +++ b/components/animated-theme-toggler.tsx @@ -0,0 +1,122 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { buttonVariants } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils/ui"; +import { RiMoonClearLine, RiSunLine } from "@remixicon/react"; + +interface AnimatedThemeTogglerProps + extends React.ComponentPropsWithoutRef<"button"> { + duration?: number; +} + +export const AnimatedThemeToggler = ({ + className, + duration = 400, + ...props +}: AnimatedThemeTogglerProps) => { + const [isDark, setIsDark] = useState(false); + const buttonRef = useRef(null); + + useEffect(() => { + const updateTheme = () => { + setIsDark(document.documentElement.classList.contains("dark")); + }; + + updateTheme(); + + const observer = new MutationObserver(updateTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + const toggleTheme = useCallback(async () => { + if (!buttonRef.current) return; + + await document.startViewTransition(() => { + flushSync(() => { + const newTheme = !isDark; + setIsDark(newTheme); + document.documentElement.classList.toggle("dark"); + localStorage.setItem("theme", newTheme ? "dark" : "light"); + }); + }).ready; + + const { top, left, width, height } = + buttonRef.current.getBoundingClientRect(); + const x = left + width / 2; + const y = top + height / 2; + const maxRadius = Math.hypot( + Math.max(left, window.innerWidth - left), + Math.max(top, window.innerHeight - top) + ); + + document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${maxRadius}px at ${x}px ${y}px)`, + ], + }, + { + duration, + easing: "ease-in-out", + pseudoElement: "::view-transition-new(root)", + } + ); + }, [isDark, duration]); + + return ( + + + + + + {isDark ? "Tema claro" : "Tema escuro"} + + + ); +}; diff --git a/components/anotacoes/note-card.tsx b/components/anotacoes/note-card.tsx new file mode 100644 index 0000000..f561ae0 --- /dev/null +++ b/components/anotacoes/note-card.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { RiDeleteBin5Line, RiEyeLine, RiPencilLine } from "@remixicon/react"; +import { CheckIcon } from "lucide-react"; +import { useMemo } from "react"; + +import type { Note } from "./types"; + +const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { + dateStyle: "medium", +}); + +interface NoteCardProps { + note: Note; + onEdit?: (note: Note) => void; + onDetails?: (note: Note) => void; + onRemove?: (note: Note) => void; +} + +export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) { + const { formattedDate, displayTitle } = useMemo(() => { + const resolvedTitle = note.title.trim().length + ? note.title + : "Anotação sem título"; + + return { + displayTitle: resolvedTitle, + formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), + }; + }, [note.createdAt, note.title]); + + const isTask = note.type === "tarefa"; + const tasks = note.tasks || []; + const completedCount = tasks.filter((t) => t.completed).length; + const totalCount = tasks.length; + + const actions = [ + { + label: "editar", + icon: , + onClick: onEdit, + variant: "default" as const, + }, + { + label: "detalhes", + icon: , + onClick: onDetails, + variant: "default" as const, + }, + { + label: "remover", + icon: , + onClick: onRemove, + variant: "destructive" as const, + }, + ].filter((action) => typeof action.onClick === "function"); + + return ( + + +
+
+

+ {displayTitle} +

+ {isTask && ( + + {completedCount}/{totalCount} concluídas + + )} +
+ + {formattedDate} + +
+ + {isTask ? ( +
+ {tasks.slice(0, 4).map((task) => ( +
+
+ {task.completed && ( + + )} +
+ + {task.text} + +
+ ))} + {tasks.length > 4 && ( +

+ +{tasks.length - 4}{" "} + {tasks.length - 4 === 1 ? "tarefa" : "tarefas"}... +

+ )} +
+ ) : ( +

+ {note.description} +

+ )} +
+ + {actions.length > 0 ? ( + + {actions.map(({ label, icon, onClick, variant }) => ( + + ))} + + ) : null} +
+ ); +} diff --git a/components/anotacoes/note-details-dialog.tsx b/components/anotacoes/note-details-dialog.tsx new file mode 100644 index 0000000..fc60994 --- /dev/null +++ b/components/anotacoes/note-details-dialog.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CheckIcon } from "lucide-react"; +import { useMemo } from "react"; + +import type { Note } from "./types"; + +const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { + dateStyle: "long", + timeStyle: "short", +}); + +interface NoteDetailsDialogProps { + note: Note | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function NoteDetailsDialog({ + note, + open, + onOpenChange, +}: NoteDetailsDialogProps) { + const { formattedDate, displayTitle } = useMemo(() => { + if (!note) { + return { formattedDate: "", displayTitle: "" }; + } + + const title = note.title.trim().length ? note.title : "Anotação sem título"; + + return { + formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), + displayTitle: title, + }; + }, [note]); + + if (!note) { + return null; + } + + const isTask = note.type === "tarefa"; + const tasks = note.tasks || []; + const completedCount = tasks.filter((t) => t.completed).length; + const totalCount = tasks.length; + + return ( + + + + + {displayTitle} + {isTask && ( + + {completedCount}/{totalCount} + + )} + + {formattedDate} + + + {isTask ? ( +
+ {tasks.map((task) => ( +
+
+ {task.completed && ( + + )} +
+ + {task.text} + +
+ ))} +
+ ) : ( +
+ {note.description} +
+ )} + + + + + + +
+
+ ); +} diff --git a/components/anotacoes/note-dialog.tsx b/components/anotacoes/note-dialog.tsx new file mode 100644 index 0000000..4b0ef49 --- /dev/null +++ b/components/anotacoes/note-dialog.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { + createNoteAction, + updateNoteAction, +} from "@/app/(dashboard)/anotacoes/actions"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Textarea } from "@/components/ui/textarea"; +import { useControlledState } from "@/hooks/use-controlled-state"; +import { useFormState } from "@/hooks/use-form-state"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, + useTransition, +} from "react"; +import { toast } from "sonner"; +import type { Note, NoteFormValues, Task } from "./types"; + +type NoteDialogMode = "create" | "update"; +interface NoteDialogProps { + mode: NoteDialogMode; + trigger?: ReactNode; + note?: Note; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +const MAX_TITLE = 30; +const MAX_DESC = 350; +const normalize = (s: string) => s.replace(/\s+/g, " ").trim(); + +const buildInitialValues = (note?: Note): NoteFormValues => ({ + title: note?.title ?? "", + description: note?.description ?? "", + type: note?.type ?? "nota", + tasks: note?.tasks ?? [], +}); + +const generateTaskId = () => { + return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +}; + +export function NoteDialog({ + mode, + trigger, + note, + open, + onOpenChange, +}: NoteDialogProps) { + const [isPending, startTransition] = useTransition(); + const [errorMessage, setErrorMessage] = useState(null); + const [newTaskText, setNewTaskText] = useState(""); + + const titleRef = useRef(null); + const descRef = useRef(null); + const newTaskRef = useRef(null); + + // Use controlled state hook for dialog open state + const [dialogOpen, setDialogOpen] = useControlledState( + open, + false, + onOpenChange + ); + + const initialState = buildInitialValues(note); + + // Use form state hook for form management + const { formState, updateField, setFormState } = + useFormState(initialState); + + useEffect(() => { + if (dialogOpen) { + setFormState(buildInitialValues(note)); + setErrorMessage(null); + setNewTaskText(""); + requestAnimationFrame(() => titleRef.current?.focus()); + } + }, [dialogOpen, note, setFormState]); + + const title = mode === "create" ? "Nova anotação" : "Editar anotação"; + const description = + mode === "create" + ? "Escolha entre uma nota simples ou uma lista de tarefas." + : "Altere o título e/ou conteúdo desta anotação."; + const submitLabel = + mode === "create" ? "Salvar anotação" : "Atualizar anotação"; + + const titleCount = formState.title.length; + const descCount = formState.description.length; + const isNote = formState.type === "nota"; + + const onlySpaces = + normalize(formState.title).length === 0 || + (isNote && normalize(formState.description).length === 0) || + (!isNote && (!formState.tasks || formState.tasks.length === 0)); + + const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC; + + const unchanged = + mode === "update" && + normalize(formState.title) === normalize(note?.title ?? "") && + normalize(formState.description) === normalize(note?.description ?? "") && + JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks); + + const disableSubmit = isPending || onlySpaces || unchanged || invalidLen; + + const handleOpenChange = useCallback( + (v: boolean) => { + setDialogOpen(v); + if (!v) setErrorMessage(null); + }, + [setDialogOpen] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") + (e.currentTarget as HTMLFormElement).requestSubmit(); + if (e.key === "Escape") handleOpenChange(false); + }, + [handleOpenChange] + ); + + const handleAddTask = useCallback(() => { + const text = normalize(newTaskText); + if (!text) return; + + const newTask: Task = { + id: generateTaskId(), + text, + completed: false, + }; + + updateField("tasks", [...(formState.tasks || []), newTask]); + setNewTaskText(""); + requestAnimationFrame(() => newTaskRef.current?.focus()); + }, [newTaskText, formState.tasks, updateField]); + + const handleRemoveTask = useCallback( + (taskId: string) => { + updateField( + "tasks", + (formState.tasks || []).filter((t) => t.id !== taskId) + ); + }, + [formState.tasks, updateField] + ); + + const handleToggleTask = useCallback( + (taskId: string) => { + updateField( + "tasks", + (formState.tasks || []).map((t) => + t.id === taskId ? { ...t, completed: !t.completed } : t + ) + ); + }, + [formState.tasks, updateField] + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(null); + + const payload = { + title: normalize(formState.title), + description: normalize(formState.description), + type: formState.type, + tasks: formState.tasks, + }; + + if (onlySpaces || invalidLen) { + setErrorMessage("Preencha os campos respeitando os limites."); + titleRef.current?.focus(); + return; + } + + if (mode === "update" && !note?.id) { + const msg = "Não foi possível identificar a anotação a ser editada."; + setErrorMessage(msg); + toast.error(msg); + return; + } + + if (unchanged) { + toast.info("Nada para atualizar."); + return; + } + + startTransition(async () => { + let result; + if (mode === "create") { + result = await createNoteAction(payload); + } else { + if (!note?.id) { + const msg = "ID da anotação não encontrado."; + setErrorMessage(msg); + toast.error(msg); + return; + } + result = await updateNoteAction({ id: note.id, ...payload }); + } + + if (result.success) { + toast.success(result.message); + setDialogOpen(false); + return; + } + setErrorMessage(result.error); + toast.error(result.error); + titleRef.current?.focus(); + }); + }, + [ + formState.title, + formState.description, + formState.type, + formState.tasks, + mode, + note, + setDialogOpen, + onlySpaces, + unchanged, + invalidLen, + ] + ); + + return ( + + {trigger ? {trigger} : null} + + + {title} + {description} + + +
+ {/* Seletor de Tipo - apenas no modo de criação */} + {mode === "create" && ( +
+ + + updateField("type", value as "nota" | "tarefa") + } + disabled={isPending} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ )} + + {/* Título */} +
+ + updateField("title", e.target.value)} + placeholder={ + isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana" + } + maxLength={MAX_TITLE} + disabled={isPending} + aria-describedby="note-title-help" + required + /> +

+ Até {MAX_TITLE} caracteres. Restantes:{" "} + {Math.max(0, MAX_TITLE - titleCount)}. +

+
+ + {/* Conteúdo - apenas para Notas */} + {isNote && ( +
+ +