From 69da27276ce84f3f63a5e5a5da307be3bbabe75f Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Mon, 9 Mar 2026 17:12:44 +0000 Subject: [PATCH] refactor(dashboard): reorganiza widgets e remove magnet-lines --- app/(dashboard)/ajustes/actions.ts | 3 - app/(dashboard)/ajustes/data.ts | 2 - app/(dashboard)/ajustes/page.tsx | 3 - app/(dashboard)/dashboard/data.ts | 3 - app/(dashboard)/dashboard/loading.tsx | 6 +- app/(dashboard)/dashboard/page.tsx | 11 +- components/ajustes/preferences-form.tsx | 36 +- components/dashboard/bill-widget.tsx | 35 + components/dashboard/bills/bill-list-item.tsx | 73 + .../dashboard/bills/bill-payment-dialog.tsx | 189 ++ components/dashboard/bills/bills-list.tsx | 29 + .../dashboard/bills/bills-widget-view.tsx | 43 + components/dashboard/boletos-widget.tsx | 388 --- .../category-breakdown-widget-view.tsx | 400 +++ .../dashboard/category-history-widget.tsx | 36 +- .../dashboard/dashboard-grid-editable.tsx | 66 +- ...-cards.tsx => dashboard-metrics-cards.tsx} | 23 +- components/dashboard/dashboard-welcome.tsx | 77 +- ...expenses-by-category-widget-with-chart.tsx | 318 +-- .../dashboard/goals-progress-widget.tsx | 152 +- .../goals-progress/goal-progress-item.tsx | 70 + .../goals-progress/goals-progress-list.tsx | 34 + .../goals-progress-widget-dialogs.tsx | 29 + .../goals-progress-widget-view.tsx | 41 + .../income-by-category-widget-with-chart.tsx | 317 +-- .../income-expense-balance-widget.tsx | 20 +- .../installment-analysis-page.tsx | 2 +- .../installment-group-card.tsx | 2 +- .../dashboard/installment-expenses-widget.tsx | 183 +- .../installment-expense-list-item.tsx | 76 + .../installment-expenses-list.tsx | 30 + .../installment-expenses-widget-view.tsx | 16 + components/dashboard/invoices-widget.tsx | 595 +--- .../dashboard/invoices/invoice-list-item.tsx | 148 + .../dashboard/invoices/invoice-logo.tsx | 59 + .../invoices/invoice-payment-dialog.tsx | 203 ++ .../dashboard/invoices/invoices-list.tsx | 29 + .../invoices/invoices-widget-view.tsx | 43 + components/dashboard/my-accounts-widget.tsx | 69 +- components/dashboard/notes-widget.tsx | 165 +- components/dashboard/notes/note-list-item.tsx | 65 + components/dashboard/notes/notes-list.tsx | 39 + .../dashboard/notes/notes-widget-dialogs.tsx | 38 + .../dashboard/notes/notes-widget-view.tsx | 48 + ...pagadores-widget.tsx => payers-widget.tsx} | 13 +- .../dashboard/payment-conditions-widget.tsx | 88 - .../dashboard/payment-methods-widget.tsx | 87 - .../dashboard/payment-overview-widget.tsx | 41 +- .../payment-breakdown-list-item.tsx | 51 + .../payment-breakdown-list.tsx | 42 + .../payment-conditions-widget.tsx | 38 + .../payment-methods-widget.tsx | 38 + .../payment-overview-widget-view.tsx | 44 + .../dashboard/payment-status-widget.tsx | 95 +- .../payment-status-category-section.tsx | 50 + .../payment-status-widget-view.tsx | 47 + .../purchases-by-category-widget.tsx | 67 +- .../dashboard/recurring-expenses-widget.tsx | 9 +- .../dashboard/top-establishments-widget.tsx | 4 +- components/dashboard/top-expenses-widget.tsx | 4 +- components/dashboard/welcome-widget.ts | 9 + components/magnet-lines.tsx | 112 - .../skeletons/section-cards-skeleton.tsx | 37 - db/schema.ts | 1 - drizzle/0018_rainy_epoch.sql | 1 + drizzle/meta/0018_snapshot.json | 2416 +++++++++++++++++ drizzle/meta/_journal.json | 269 +- lib/dashboard/accounts.ts | 2 +- lib/dashboard/bills-helpers.ts | 53 + lib/dashboard/{boletos.ts => bills.ts} | 47 +- .../categories/category-breakdown.ts | 121 + lib/dashboard/categories/category-details.ts | 2 +- lib/dashboard/categories/category-history.ts | 67 +- .../categories/expenses-by-category.ts | 127 +- .../categories/income-by-category.ts | 136 +- lib/dashboard/common.ts | 9 - .../{metrics.ts => dashboard-metrics.ts} | 36 +- .../expenses/installment-analysis.ts | 19 +- .../expenses/installment-expenses.ts | 2 +- lib/dashboard/expenses/recurring-expenses.ts | 2 +- lib/dashboard/expenses/top-expenses.ts | 30 +- lib/dashboard/fetch-dashboard-data.ts | 12 +- lib/dashboard/goals-progress-helpers.ts | 45 + lib/dashboard/goals-progress.ts | 2 +- lib/dashboard/income-expense-balance.ts | 75 +- lib/dashboard/installment-expenses-helpers.ts | 116 + lib/dashboard/invoices-helpers.ts | 104 + lib/dashboard/invoices.ts | 122 +- lib/dashboard/lancamento-filters.ts | 56 + lib/dashboard/notes-mappers.ts | 15 + lib/dashboard/notifications.ts | 151 +- lib/dashboard/pagadores.ts | 2 +- lib/dashboard/payment-breakdown-formatters.ts | 10 + lib/dashboard/payment-overview-tabs.ts | 11 + lib/dashboard/payments/payment-conditions.ts | 40 +- lib/dashboard/payments/payment-methods.ts | 40 +- lib/dashboard/payments/payment-status.ts | 19 +- lib/dashboard/purchases-by-category.ts | 28 +- lib/dashboard/top-establishments.ts | 32 +- lib/dashboard/use-bill-widget-controller.ts | 46 + .../use-goals-progress-widget-controller.ts | 56 + .../use-invoices-widget-controller.ts | 46 + lib/dashboard/use-notes-widget-controller.ts | 65 + .../use-payment-dialog-controller.ts | 110 + .../use-payment-overview-widget-controller.ts | 28 + lib/dashboard/widgets/widgets-config.tsx | 12 +- 106 files changed, 6072 insertions(+), 3601 deletions(-) create mode 100644 components/dashboard/bill-widget.tsx create mode 100644 components/dashboard/bills/bill-list-item.tsx create mode 100644 components/dashboard/bills/bill-payment-dialog.tsx create mode 100644 components/dashboard/bills/bills-list.tsx create mode 100644 components/dashboard/bills/bills-widget-view.tsx delete mode 100644 components/dashboard/boletos-widget.tsx create mode 100644 components/dashboard/category-breakdown/category-breakdown-widget-view.tsx rename components/dashboard/{section-cards.tsx => dashboard-metrics-cards.tsx} (81%) create mode 100644 components/dashboard/goals-progress/goal-progress-item.tsx create mode 100644 components/dashboard/goals-progress/goals-progress-list.tsx create mode 100644 components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx create mode 100644 components/dashboard/goals-progress/goals-progress-widget-view.tsx create mode 100644 components/dashboard/installment-expenses/installment-expense-list-item.tsx create mode 100644 components/dashboard/installment-expenses/installment-expenses-list.tsx create mode 100644 components/dashboard/installment-expenses/installment-expenses-widget-view.tsx create mode 100644 components/dashboard/invoices/invoice-list-item.tsx create mode 100644 components/dashboard/invoices/invoice-logo.tsx create mode 100644 components/dashboard/invoices/invoice-payment-dialog.tsx create mode 100644 components/dashboard/invoices/invoices-list.tsx create mode 100644 components/dashboard/invoices/invoices-widget-view.tsx create mode 100644 components/dashboard/notes/note-list-item.tsx create mode 100644 components/dashboard/notes/notes-list.tsx create mode 100644 components/dashboard/notes/notes-widget-dialogs.tsx create mode 100644 components/dashboard/notes/notes-widget-view.tsx rename components/dashboard/{pagadores-widget.tsx => payers-widget.tsx} (92%) delete mode 100644 components/dashboard/payment-conditions-widget.tsx delete mode 100644 components/dashboard/payment-methods-widget.tsx create mode 100644 components/dashboard/payment-overview/payment-breakdown-list-item.tsx create mode 100644 components/dashboard/payment-overview/payment-breakdown-list.tsx create mode 100644 components/dashboard/payment-overview/payment-conditions-widget.tsx create mode 100644 components/dashboard/payment-overview/payment-methods-widget.tsx create mode 100644 components/dashboard/payment-overview/payment-overview-widget-view.tsx create mode 100644 components/dashboard/payment-status/payment-status-category-section.tsx create mode 100644 components/dashboard/payment-status/payment-status-widget-view.tsx create mode 100644 components/dashboard/welcome-widget.ts delete mode 100644 components/magnet-lines.tsx delete mode 100644 components/shared/skeletons/section-cards-skeleton.tsx create mode 100644 drizzle/0018_rainy_epoch.sql create mode 100644 drizzle/meta/0018_snapshot.json create mode 100644 lib/dashboard/bills-helpers.ts rename lib/dashboard/{boletos.ts => bills.ts} (65%) create mode 100644 lib/dashboard/categories/category-breakdown.ts delete mode 100644 lib/dashboard/common.ts rename lib/dashboard/{metrics.ts => dashboard-metrics.ts} (86%) create mode 100644 lib/dashboard/goals-progress-helpers.ts create mode 100644 lib/dashboard/installment-expenses-helpers.ts create mode 100644 lib/dashboard/invoices-helpers.ts create mode 100644 lib/dashboard/lancamento-filters.ts create mode 100644 lib/dashboard/notes-mappers.ts create mode 100644 lib/dashboard/payment-breakdown-formatters.ts create mode 100644 lib/dashboard/payment-overview-tabs.ts create mode 100644 lib/dashboard/use-bill-widget-controller.ts create mode 100644 lib/dashboard/use-goals-progress-widget-controller.ts create mode 100644 lib/dashboard/use-invoices-widget-controller.ts create mode 100644 lib/dashboard/use-notes-widget-controller.ts create mode 100644 lib/dashboard/use-payment-dialog-controller.ts create mode 100644 lib/dashboard/use-payment-overview-widget-controller.ts diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index 8b6229a..f1aeab6 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -54,7 +54,6 @@ const deleteAccountSchema = z.object({ }); const updatePreferencesSchema = z.object({ - disableMagnetlines: z.boolean(), extratoNoteAsColumn: z.boolean(), lancamentosColumnOrder: z.array(z.string()).nullable(), systemFont: z.enum(FONT_KEYS).default(DEFAULT_FONT_KEY), @@ -403,7 +402,6 @@ export async function updatePreferencesAction( await db .update(schema.preferenciasUsuario) .set({ - disableMagnetlines: validated.disableMagnetlines, extratoNoteAsColumn: validated.extratoNoteAsColumn, lancamentosColumnOrder: validated.lancamentosColumnOrder, systemFont: validated.systemFont, @@ -415,7 +413,6 @@ export async function updatePreferencesAction( // Create new preferences await db.insert(schema.preferenciasUsuario).values({ userId: session.user.id, - disableMagnetlines: validated.disableMagnetlines, extratoNoteAsColumn: validated.extratoNoteAsColumn, lancamentosColumnOrder: validated.lancamentosColumnOrder, systemFont: validated.systemFont, diff --git a/app/(dashboard)/ajustes/data.ts b/app/(dashboard)/ajustes/data.ts index 5c92561..eaab79a 100644 --- a/app/(dashboard)/ajustes/data.ts +++ b/app/(dashboard)/ajustes/data.ts @@ -4,7 +4,6 @@ import { db, schema } from "@/lib/db"; import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index"; export interface UserPreferences { - disableMagnetlines: boolean; extratoNoteAsColumn: boolean; lancamentosColumnOrder: string[] | null; systemFont: FontKey; @@ -34,7 +33,6 @@ export async function fetchUserPreferences( ): Promise { const result = await db .select({ - disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines, extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn, lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder, systemFont: schema.preferenciasUsuario.systemFont, diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 269aa2a..23e34ae 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -67,9 +67,6 @@ export default async function Page() {

{ const result = await db .select({ - disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines, dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets, }) .from(schema.preferenciasUsuario) @@ -19,7 +17,6 @@ export async function fetchUserDashboardPreferences( .limit(1); return { - disableMagnetlines: result[0]?.disableMagnetlines ?? false, dashboardWidgets: result[0]?.dashboardWidgets ?? null, }; } diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx index 6d4ed30..a5a5d25 100644 --- a/app/(dashboard)/dashboard/loading.tsx +++ b/app/(dashboard)/dashboard/loading.tsx @@ -4,8 +4,10 @@ import { Skeleton } from "@/components/ui/skeleton"; export default function DashboardLoading() { return (
- {/* Welcome Banner skeleton */} - +
+ + +
{/* Month Picker skeleton */} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index fd5da45..c5277f1 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,6 +1,6 @@ import { DashboardGridEditable } from "@/components/dashboard/dashboard-grid-editable"; +import { DashboardMetricsCards } from "@/components/dashboard/dashboard-metrics-cards"; import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome"; -import { SectionCards } from "@/components/dashboard/section-cards"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUser } from "@/lib/auth/server"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; @@ -41,7 +41,7 @@ export default async function Page({ searchParams }: PageProps) { fetchLancamentoFilterSources(user.id), getRecentEstablishmentsAction(), ]); - const { disableMagnetlines, dashboardWidgets } = preferences; + const { dashboardWidgets } = preferences; const sluggedFilters = buildSluggedFilters(filterSources); const { pagadorOptions, @@ -57,12 +57,9 @@ export default async function Page({ searchParams }: PageProps) { return (
- + - + { const result = await updatePreferencesAction({ - disableMagnetlines: magnetlinesDisabled, extratoNoteAsColumn, lancamentosColumnOrder: columnOrder, systemFont: selectedSystemFont, @@ -274,35 +269,6 @@ export function PreferencesForm({ -
- - {/* Seção: Dashboard */} -
-
-

Dashboard

-

- Opções que afetam a experiência no painel principal. -

-
- -
-
- -

- Remove o recurso de linhas magnéticas do sistema. -

-
- -
-
-
+
+ + ); +} diff --git a/components/dashboard/bills/bill-payment-dialog.tsx b/components/dashboard/bills/bill-payment-dialog.tsx new file mode 100644 index 0000000..eacfdec --- /dev/null +++ b/components/dashboard/bills/bill-payment-dialog.tsx @@ -0,0 +1,189 @@ +import { + RiBarcodeFill, + RiCheckboxCircleLine, + RiLoader4Line, + RiMoneyDollarCircleLine, +} from "@remixicon/react"; +import MoneyValues from "@/components/shared/money-values"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { DashboardBill } from "@/lib/dashboard/bills"; +import { + type BillDialogState, + formatBillDateLabel, + getBillStatusBadgeVariant, +} from "@/lib/dashboard/bills-helpers"; + +type BillPaymentDialogProps = { + bill: DashboardBill | null; + open: boolean; + modalState: BillDialogState; + isPending: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export function BillPaymentDialog({ + bill, + open, + modalState, + isPending, + onClose, + onConfirm, +}: BillPaymentDialogProps) { + const isProcessing = modalState === "processing" || isPending; + const dueLabel = bill + ? formatBillDateLabel(bill.dueDate, "Vencimento:") + : null; + + return ( + { + if (nextOpen || isProcessing) { + return; + } + onClose(); + }} + > + { + if (isProcessing) { + event.preventDefault(); + } + }} + onPointerDownOutside={(event) => { + if (isProcessing) { + event.preventDefault(); + } + }} + > + {modalState === "success" ? ( +
+
+ +
+
+ + Pagamento registrado! + + + Atualizamos o status do boleto para pago. Em instantes ele + aparecerá como baixado no histórico. + +
+ + + +
+ ) : ( + <> + + Confirmar pagamento do boleto + + Confirme os dados para registrar o pagamento. Você poderá editar + o lançamento depois, se necessário. + + + + {bill ? ( +
+
+
+
+
+ +
+
+

+ Boleto +

+

+ {bill.name} +

+
+
+ {dueLabel ? ( +
+

+ {dueLabel} +

+
+ ) : null} +
+
+ +
+
+
+ + + Valor do Boleto + +
+ +
+
+
+ + + Status + +
+ + {bill.isSettled ? "Pago" : "Pendente"} + +
+
+
+ ) : null} + + + + + + + )} +
+
+ ); +} diff --git a/components/dashboard/bills/bills-list.tsx b/components/dashboard/bills/bills-list.tsx new file mode 100644 index 0000000..962ccc0 --- /dev/null +++ b/components/dashboard/bills/bills-list.tsx @@ -0,0 +1,29 @@ +import { RiBarcodeFill } from "@remixicon/react"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import type { DashboardBill } from "@/lib/dashboard/bills"; +import { BillListItem } from "./bill-list-item"; + +type BillsListProps = { + bills: DashboardBill[]; + onPay: (billId: string) => void; +}; + +export function BillsList({ bills, onPay }: BillsListProps) { + if (bills.length === 0) { + return ( + } + title="Nenhum boleto cadastrado para o período selecionado" + description="Cadastre boletos para monitorar os pagamentos aqui." + /> + ); + } + + return ( +
    + {bills.map((bill) => ( + + ))} +
+ ); +} diff --git a/components/dashboard/bills/bills-widget-view.tsx b/components/dashboard/bills/bills-widget-view.tsx new file mode 100644 index 0000000..d901dea --- /dev/null +++ b/components/dashboard/bills/bills-widget-view.tsx @@ -0,0 +1,43 @@ +import type { DashboardBill } from "@/lib/dashboard/bills"; +import type { BillDialogState } from "@/lib/dashboard/bills-helpers"; +import { BillPaymentDialog } from "./bill-payment-dialog"; +import { BillsList } from "./bills-list"; + +type BillsWidgetViewProps = { + bills: DashboardBill[]; + selectedBill: DashboardBill | null; + isModalOpen: boolean; + modalState: BillDialogState; + isPending: boolean; + onOpenPaymentDialog: (billId: string) => void; + onClosePaymentDialog: () => void; + onConfirmPayment: () => void; +}; + +export function BillsWidgetView({ + bills, + selectedBill, + isModalOpen, + modalState, + isPending, + onOpenPaymentDialog, + onClosePaymentDialog, + onConfirmPayment, +}: BillsWidgetViewProps) { + return ( + <> +
+ +
+ + + + ); +} diff --git a/components/dashboard/boletos-widget.tsx b/components/dashboard/boletos-widget.tsx deleted file mode 100644 index 09242e6..0000000 --- a/components/dashboard/boletos-widget.tsx +++ /dev/null @@ -1,388 +0,0 @@ -"use client"; - -import { - RiBarcodeFill, - RiCheckboxCircleFill, - RiCheckboxCircleLine, - RiLoader4Line, - RiMoneyDollarCircleLine, -} from "@remixicon/react"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useTransition } from "react"; -import { toast } from "sonner"; -import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions"; -import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; -import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter as ModalFooter, -} from "@/components/ui/dialog"; -import type { DashboardBoleto } from "@/lib/dashboard/boletos"; -import { cn } from "@/lib/utils/ui"; -import { Badge } from "../ui/badge"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type BoletosWidgetProps = { - boletos: DashboardBoleto[]; -}; - -type ModalState = "idle" | "processing" | "success"; - -const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - timeZone: "UTC", -}); - -const buildDateLabel = (value: string | null, prefix?: string) => { - if (!value) { - return null; - } - - const [year, month, day] = value.split("-").map((part) => Number(part)); - if (!year || !month || !day) { - return null; - } - - const formatted = DATE_FORMATTER.format( - new Date(Date.UTC(year, month - 1, day)), - ); - - return prefix ? `${prefix} ${formatted}` : formatted; -}; - -const buildStatusLabel = (boleto: DashboardBoleto) => { - if (boleto.isSettled) { - return buildDateLabel(boleto.boletoPaymentDate, "Pago em"); - } - - return buildDateLabel(boleto.dueDate, "Vence em"); -}; - -const getTodayDateString = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -export function BoletosWidget({ boletos }: BoletosWidgetProps) { - const router = useRouter(); - const [items, setItems] = useState(boletos); - const [selectedId, setSelectedId] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalState, setModalState] = useState("idle"); - const [isPending, startTransition] = useTransition(); - - useEffect(() => { - setItems(boletos); - }, [boletos]); - - const selectedBoleto = useMemo( - () => items.find((boleto) => boleto.id === selectedId) ?? null, - [items, selectedId], - ); - - const isProcessing = modalState === "processing" || isPending; - - const selectedBoletoDueLabel = selectedBoleto - ? buildDateLabel(selectedBoleto.dueDate, "Vencimento:") - : null; - - const handleOpenModal = (boletoId: string) => { - setSelectedId(boletoId); - setModalState("idle"); - setIsModalOpen(true); - }; - - const resetModalState = () => { - setIsModalOpen(false); - setSelectedId(null); - setModalState("idle"); - }; - - const handleConfirmPayment = () => { - if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) { - return; - } - - setModalState("processing"); - - startTransition(async () => { - const result = await toggleLancamentoSettlementAction({ - id: selectedBoleto.id, - value: true, - }); - - if (!result.success) { - toast.error(result.error); - setModalState("idle"); - return; - } - - setItems((previous) => - previous.map((boleto) => - boleto.id === selectedBoleto.id - ? { - ...boleto, - isSettled: true, - boletoPaymentDate: getTodayDateString(), - } - : boleto, - ), - ); - toast.success(result.message); - router.refresh(); - setModalState("success"); - }); - }; - - const getStatusBadgeVariant = (status: string): "success" | "info" => { - const normalizedStatus = status.toLowerCase(); - if (normalizedStatus === "pendente") { - return "info"; - } - return "success"; - }; - - return ( - <> - - {items.length === 0 ? ( - } - title="Nenhum boleto cadastrado para o período selecionado" - description="Cadastre boletos para monitorar os pagamentos aqui." - /> - ) : ( -
    - {items.map((boleto) => { - const statusLabel = buildStatusLabel(boleto); - const isOverdue = (() => { - if (boleto.isSettled || !boleto.dueDate) return false; - const [y, m, d] = boleto.dueDate.split("-").map(Number); - if (!y || !m || !d) return false; - return new Date(Date.UTC(y, m - 1, d)) < new Date(); - })(); - - return ( -
  • -
    - - -
    - - {boleto.name} - -
    - - {statusLabel} - -
    -
    -
    - -
    - - -
    -
  • - ); - })} -
- )} -
- - { - if (!open) { - if (isProcessing) { - return; - } - resetModalState(); - return; - } - setIsModalOpen(true); - }} - > - { - if (isProcessing) { - event.preventDefault(); - return; - } - resetModalState(); - }} - onPointerDownOutside={(event) => { - if (isProcessing) { - event.preventDefault(); - } - }} - > - {modalState === "success" ? ( -
-
- -
-
- - Pagamento registrado! - - - Atualizamos o status do boleto para pago. Em instantes ele - aparecerá como baixado no histórico. - -
- - - -
- ) : ( - <> - - Confirmar pagamento do boleto - - Confirme os dados para registrar o pagamento. Você poderá - editar o lançamento depois, se necessário. - - - - {selectedBoleto ? ( -
-
-
-
-
- -
-
-

- Boleto -

-

- {selectedBoleto.name} -

-
-
- {selectedBoletoDueLabel ? ( -
-

- {selectedBoletoDueLabel} -

-
- ) : null} -
-
- -
-
-
- - - Valor do Boleto - -
- -
-
-
- - - Status - -
- - {selectedBoleto.isSettled ? "Pago" : "Pendente"} - -
-
-
- ) : null} - - - - - - - )} -
-
- - ); -} diff --git a/components/dashboard/category-breakdown/category-breakdown-widget-view.tsx b/components/dashboard/category-breakdown/category-breakdown-widget-view.tsx new file mode 100644 index 0000000..0d7bd7f --- /dev/null +++ b/components/dashboard/category-breakdown/category-breakdown-widget-view.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { + RiArrowDownSFill, + RiArrowUpSFill, + RiExternalLinkLine, + RiListUnordered, + RiPieChart2Line, + RiPieChartLine, + RiWallet3Line, +} from "@remixicon/react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Pie, PieChart, Tooltip } from "recharts"; +import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; +import MoneyValues from "@/components/shared/money-values"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { DashboardCategoryBreakdownData } from "@/lib/dashboard/categories/category-breakdown"; +import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; +import { formatPercentage as formatPercentageValue } from "@/lib/utils/percentage"; +import { formatPeriodForUrl } from "@/lib/utils/period"; + +type CategoryBreakdownVariant = "income" | "expense"; + +type CategoryBreakdownWidgetViewProps = { + data: DashboardCategoryBreakdownData; + period: string; + variant: CategoryBreakdownVariant; +}; + +const CATEGORY_BREAKDOWN_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "var(--chart-1)", + "var(--chart-2)", +]; + +const VARIANT_CONFIG = { + income: { + emptyTitle: "Nenhuma receita encontrada", + emptyDescription: + "Quando houver receitas registradas, elas aparecerão aqui.", + shareLabel: "receita total", + percentageDigits: 1, + changeClassName: { + increase: "text-success", + decrease: "text-destructive", + }, + listItemClassName: + "flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0", + includeBudgetAmount: true, + }, + expense: { + emptyTitle: "Nenhuma despesa encontrada", + emptyDescription: + "Quando houver despesas registradas, elas aparecerão aqui.", + shareLabel: "despesa total", + percentageDigits: 0, + changeClassName: { + increase: "text-destructive", + decrease: "text-success", + }, + listItemClassName: + "flex flex-col py-2 border-b border-dashed last:border-0", + includeBudgetAmount: false, + }, +} as const; + +const formatPercentage = (value: number, digits: number) => + formatPercentageValue(value, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + absolute: true, + }); + +export function CategoryBreakdownWidgetView({ + data, + period, + variant, +}: CategoryBreakdownWidgetViewProps) { + const [activeTab, setActiveTab] = useState<"list" | "chart">("list"); + const periodParam = formatPeriodForUrl(period); + const config = VARIANT_CONFIG[variant]; + + const chartConfig = useMemo(() => { + const nextConfig: ChartConfig = {}; + + if (data.categories.length <= 7) { + data.categories.forEach((category, index) => { + nextConfig[category.categoryId] = { + label: category.categoryName, + color: + CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length], + }; + }); + } else { + const topCategories = data.categories.slice(0, 7); + topCategories.forEach((category, index) => { + nextConfig[category.categoryId] = { + label: category.categoryName, + color: + CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length], + }; + }); + nextConfig.outros = { + label: "Outros", + color: "var(--chart-6)", + }; + } + + return nextConfig; + }, [data.categories]); + + const chartData = useMemo(() => { + if (data.categories.length <= 7) { + return data.categories.map((category) => ({ + category: category.categoryId, + name: category.categoryName, + value: category.currentAmount, + percentage: category.percentageOfTotal, + fill: chartConfig[category.categoryId]?.color, + })); + } + + const topCategories = data.categories.slice(0, 7); + const otherCategories = data.categories.slice(7); + const otherTotal = otherCategories.reduce( + (sum, category) => sum + category.currentAmount, + 0, + ); + const otherPercentage = otherCategories.reduce( + (sum, category) => sum + category.percentageOfTotal, + 0, + ); + + const groupedData = topCategories.map((category) => ({ + category: category.categoryId, + name: category.categoryName, + value: category.currentAmount, + percentage: category.percentageOfTotal, + fill: chartConfig[category.categoryId]?.color, + })); + + if (otherCategories.length > 0) { + groupedData.push({ + category: "outros", + name: "Outros", + value: otherTotal, + percentage: otherPercentage, + fill: chartConfig.outros?.color, + }); + } + + return groupedData; + }, [data.categories, chartConfig]); + + if (data.categories.length === 0) { + return ( + } + title={config.emptyTitle} + description={config.emptyDescription} + /> + ); + } + + return ( + setActiveTab(value as "list" | "chart")} + className="w-full" + > +
+ + + + Lista + + + + Gráfico + + +
+ + +
+ {data.categories.map((category, index) => { + const hasIncrease = + category.percentageChange !== null && + category.percentageChange > 0; + const hasDecrease = + category.percentageChange !== null && + category.percentageChange < 0; + const hasBudget = category.budgetAmount !== null; + const budgetExceeded = + hasBudget && + category.budgetUsedPercentage !== null && + category.budgetUsedPercentage > 100; + const exceededAmount = + budgetExceeded && category.budgetAmount + ? category.currentAmount - category.budgetAmount + : 0; + const changeClassName = hasIncrease + ? config.changeClassName.increase + : hasDecrease + ? config.changeClassName.decrease + : "text-muted-foreground"; + + return ( +
+
+
+ + +
+
+ + + {category.categoryName} + + + +
+
+ + {formatPercentage( + category.percentageOfTotal, + config.percentageDigits, + )}{" "} + da {config.shareLabel} + +
+
+
+ +
+ + {category.percentageChange !== null ? ( + + {hasIncrease ? ( + + ) : null} + {hasDecrease ? ( + + ) : null} + {formatPercentage( + category.percentageChange, + config.percentageDigits, + )} + + ) : null} +
+
+ + {hasBudget && category.budgetUsedPercentage !== null ? ( +
+ + + {budgetExceeded ? ( + <> + {formatPercentage( + category.budgetUsedPercentage, + config.percentageDigits, + )}{" "} + do limite + {config.includeBudgetAmount && + category.budgetAmount !== null + ? ` ${formatCurrency(category.budgetAmount)}` + : ""}{" "} + - excedeu em {formatCurrency(exceededAmount)} + + ) : ( + <> + {formatPercentage( + category.budgetUsedPercentage, + config.percentageDigits, + )}{" "} + do limite + {config.includeBudgetAmount && + category.budgetAmount !== null + ? ` ${formatCurrency(category.budgetAmount)}` + : ""} + + )} + +
+ ) : null} +
+ ); + })} +
+
+ + +
+ + + + formatPercentage( + (payload as { percentage?: number } | undefined) + ?.percentage ?? 0, + config.percentageDigits, + ) + } + outerRadius={75} + dataKey="value" + nameKey="category" + /> + { + if (!active || !payload?.length) { + return null; + } + + const entry = payload[0]?.payload; + if (!entry) { + return null; + } + + return ( +
+
+
+ + {entry.name} + + + {formatCurrency(entry.value)} + + + {formatPercentage( + entry.percentage, + config.percentageDigits, + )}{" "} + do total + +
+
+
+ ); + }} + /> +
+
+ +
+ {chartData.map((entry, index) => ( +
+
+ + {entry.name} + +
+ ))} +
+
+ + + ); +} diff --git a/components/dashboard/category-history-widget.tsx b/components/dashboard/category-history-widget.tsx index ac91f6a..00eed86 100644 --- a/components/dashboard/category-history-widget.tsx +++ b/components/dashboard/category-history-widget.tsx @@ -6,6 +6,7 @@ import { } from "@remixicon/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -26,9 +27,9 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history"; import { CATEGORY_COLORS } from "@/lib/utils/category-colors"; +import { formatCurrency, formatCurrencyCompact } from "@/lib/utils/currency"; import { getIconComponent } from "@/lib/utils/icons"; type CategoryHistoryWidgetProps = { @@ -124,33 +125,6 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) { return config; }, [filteredCategories]); - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); - }; - - const formatCurrencyCompact = (value: number) => { - if (value >= 1000) { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - notation: "compact", - }).format(value); - } - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); - }; - const handleAddCategory = (categoryId: string) => { if ( categoryId && @@ -217,7 +191,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) { style={{ borderColor: color }} > {IconComponent ? ( - + + + ) : (
formatCurrencyCompact(Number(value))} /> { diff --git a/components/dashboard/dashboard-grid-editable.tsx b/components/dashboard/dashboard-grid-editable.tsx index 9144858..8603640 100644 --- a/components/dashboard/dashboard-grid-editable.tsx +++ b/components/dashboard/dashboard-grid-editable.tsx @@ -23,15 +23,15 @@ import { RiEyeOffLine, RiTodoLine, } from "@remixicon/react"; -import { useCallback, useMemo, useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { NoteDialog } from "@/components/anotacoes/note-dialog"; import { SortableWidget } from "@/components/dashboard/sortable-widget"; import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog"; import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog"; import type { SelectOption } from "@/components/lancamentos/types"; +import { ExpandableWidgetCard } from "@/components/shared/expandable-widget-card"; import { Button } from "@/components/ui/button"; -import WidgetCard from "@/components/widget-card"; import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import { resetWidgetPreferences, @@ -58,6 +58,8 @@ type DashboardGridEditableProps = { }; }; +const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id); + export function DashboardGridEditable({ data, period, @@ -68,9 +70,8 @@ export function DashboardGridEditable({ const [isPending, startTransition] = useTransition(); // Initialize widget order and hidden state - const defaultOrder = widgetsConfig.map((w) => w.id); const [widgetOrder, setWidgetOrder] = useState( - initialPreferences?.order ?? defaultOrder, + initialPreferences?.order ?? DEFAULT_WIDGET_ORDER, ); const [hiddenWidgets, setHiddenWidgets] = useState( initialPreferences?.hidden ?? [], @@ -118,7 +119,7 @@ export function DashboardGridEditable({ return ordered; }, [widgetOrder, hiddenWidgets]); - const handleDragEnd = useCallback((event: DragEndEvent) => { + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { @@ -128,44 +129,41 @@ export function DashboardGridEditable({ return arrayMove(items, oldIndex, newIndex); }); } - }, []); + }; - const handleToggleWidget = useCallback( - (widgetId: string) => { - const newHidden = hiddenWidgets.includes(widgetId) - ? hiddenWidgets.filter((id) => id !== widgetId) - : [...hiddenWidgets, widgetId]; + const handleToggleWidget = (widgetId: string) => { + const newHidden = hiddenWidgets.includes(widgetId) + ? hiddenWidgets.filter((id) => id !== widgetId) + : [...hiddenWidgets, widgetId]; - setHiddenWidgets(newHidden); + setHiddenWidgets(newHidden); - // Salvar automaticamente ao toggle - startTransition(async () => { - await updateWidgetPreferences({ - order: widgetOrder, - hidden: newHidden, - }); + // Salvar automaticamente ao toggle + startTransition(async () => { + await updateWidgetPreferences({ + order: widgetOrder, + hidden: newHidden, }); - }, - [hiddenWidgets, widgetOrder], - ); + }); + }; - const handleHideWidget = useCallback((widgetId: string) => { + const handleHideWidget = (widgetId: string) => { setHiddenWidgets((prev) => [...prev, widgetId]); - }, []); + }; - const handleStartEditing = useCallback(() => { + const handleStartEditing = () => { setOriginalOrder(widgetOrder); setOriginalHidden(hiddenWidgets); setIsEditing(true); - }, [widgetOrder, hiddenWidgets]); + }; - const handleCancelEditing = useCallback(() => { + const handleCancelEditing = () => { setWidgetOrder(originalOrder); setHiddenWidgets(originalHidden); setIsEditing(false); - }, [originalOrder, originalHidden]); + }; - const handleSave = useCallback(() => { + const handleSave = () => { startTransition(async () => { const result = await updateWidgetPreferences({ order: widgetOrder, @@ -179,21 +177,21 @@ export function DashboardGridEditable({ toast.error(result.error ?? "Erro ao salvar"); } }); - }, [widgetOrder, hiddenWidgets]); + }; - const handleReset = useCallback(() => { + const handleReset = () => { startTransition(async () => { const result = await resetWidgetPreferences(); if (result.success) { - setWidgetOrder(defaultOrder); + setWidgetOrder(DEFAULT_WIDGET_ORDER); setHiddenWidgets([]); toast.success("Preferências restauradas!"); } else { toast.error(result.error ?? "Erro ao restaurar"); } }); - }, [defaultOrder]); + }; return (
@@ -360,14 +358,14 @@ export function DashboardGridEditable({
)} - {widget.component({ data, period })} - +
))} diff --git a/components/dashboard/section-cards.tsx b/components/dashboard/dashboard-metrics-cards.tsx similarity index 81% rename from components/dashboard/section-cards.tsx rename to components/dashboard/dashboard-metrics-cards.tsx index 61bbd00..474f961 100644 --- a/components/dashboard/section-cards.tsx +++ b/components/dashboard/dashboard-metrics-cards.tsx @@ -7,6 +7,7 @@ import { RiIncreaseDecreaseLine, RiSubtractLine, } from "@remixicon/react"; +import MoneyValues from "@/components/shared/money-values"; import { Card, CardAction, @@ -14,10 +15,10 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { DashboardCardMetrics } from "@/lib/dashboard/metrics"; -import MoneyValues from "../money-values"; +import type { DashboardCardMetrics } from "@/lib/dashboard/dashboard-metrics"; +import { formatPercentage } from "@/lib/utils/percentage"; -type SectionCardsProps = { +type DashboardMetricsCardsProps = { metrics: DashboardCardMetrics; }; @@ -70,7 +71,11 @@ const getPercentChange = (current: number, previous: number): string => { const change = ((current - previous) / Math.abs(previous)) * 100; return Number.isFinite(change) && Math.abs(change) < 1000000 - ? `${change > 0 ? "+" : ""}${change.toFixed(1)}%` + ? formatPercentage(change, { + maximumFractionDigits: 1, + minimumFractionDigits: 1, + signDisplay: "always", + }) : "—"; }; @@ -82,7 +87,7 @@ const getTrendColor = (trend: Trend, invertTrend: boolean): string => { : "text-destructive border-destructive"; }; -export function SectionCards({ metrics }: SectionCardsProps) { +export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) { return (
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => { @@ -94,8 +99,8 @@ export function SectionCards({ metrics }: SectionCardsProps) { return ( - - + + {label} @@ -108,9 +113,9 @@ export function SectionCards({ metrics }: SectionCardsProps) {
- Mês anterior + mês anterior
-
+
diff --git a/components/dashboard/dashboard-welcome.tsx b/components/dashboard/dashboard-welcome.tsx index 95395ae..0d634c6 100644 --- a/components/dashboard/dashboard-welcome.tsx +++ b/components/dashboard/dashboard-welcome.tsx @@ -1,79 +1,18 @@ -"use client"; +import { formatCurrentDate, getGreeting } from "./welcome-widget"; -import MagnetLines from "../magnet-lines"; -import { Card } from "../ui/card"; - -type DashboardWelcomeProps = { - name?: string | null; - disableMagnetlines?: boolean; -}; - -const capitalizeFirstLetter = (value: string) => - value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value; - -const formatCurrentDate = (date = new Date()) => { - const formatted = new Intl.DateTimeFormat("pt-BR", { - weekday: "long", - day: "numeric", - month: "long", - year: "numeric", - hour12: false, - timeZone: "America/Sao_Paulo", - }).format(date); - - return capitalizeFirstLetter(formatted); -}; - -const getGreeting = () => { - const now = new Date(); - - // Get hour in Brasilia timezone - const brasiliaHour = new Intl.DateTimeFormat("pt-BR", { - hour: "numeric", - hour12: false, - timeZone: "America/Sao_Paulo", - }).format(now); - - const hour = parseInt(brasiliaHour, 10); - - if (hour >= 5 && hour < 12) { - return "Bom dia"; - } else if (hour >= 12 && hour < 18) { - return "Boa tarde"; - } else { - return "Boa noite"; - } -}; - -export function DashboardWelcome({ - name, - disableMagnetlines = false, -}: DashboardWelcomeProps) { +export function DashboardWelcome({ name }: { name?: string | null }) { const displayName = name && name.trim().length > 0 ? name : "Administrador"; const formattedDate = formatCurrentDate(); const greeting = getGreeting(); return ( - -
- -
-
-

- {greeting}, {displayName}! +
+
+

+ {greeting}, {displayName}

-

{formattedDate}

+

{formattedDate}

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

    - {item.categoryName} -

    -

    - de{" "} - -

    -
    -
    - -
    - - {formatPercentage(percentageDelta, true)} - - -
    -
    -
    - -
    -
  • - ); - })} -
- - -
+ ); } diff --git a/components/dashboard/goals-progress/goal-progress-item.tsx b/components/dashboard/goals-progress/goal-progress-item.tsx new file mode 100644 index 0000000..eb3b203 --- /dev/null +++ b/components/dashboard/goals-progress/goal-progress-item.tsx @@ -0,0 +1,70 @@ +import { RiPencilLine } from "@remixicon/react"; +import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; +import MoneyValues from "@/components/shared/money-values"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import type { GoalProgressItem as GoalProgressItemData } from "@/lib/dashboard/goals-progress"; +import { + clampGoalProgress, + formatGoalProgressPercentage, + getGoalProgressStatusColorClass, +} from "@/lib/dashboard/goals-progress-helpers"; + +type GoalProgressItemProps = { + item: GoalProgressItemData; + index: number; + onEdit: (item: GoalProgressItemData) => void; +}; + +export function GoalProgressItem({ + item, + index, + onEdit, +}: GoalProgressItemProps) { + const statusColor = getGoalProgressStatusColorClass(item.status); + const progressValue = clampGoalProgress(item.usedPercentage, 0, 100); + const percentageDelta = item.usedPercentage - 100; + + return ( +
  • +
    +
    + +
    +

    + {item.categoryName} +

    +

    + de{" "} + +

    +
    +
    + +
    + + {formatGoalProgressPercentage(percentageDelta, true)} + + +
    +
    +
    + +
    +
  • + ); +} diff --git a/components/dashboard/goals-progress/goals-progress-list.tsx b/components/dashboard/goals-progress/goals-progress-list.tsx new file mode 100644 index 0000000..66188b5 --- /dev/null +++ b/components/dashboard/goals-progress/goals-progress-list.tsx @@ -0,0 +1,34 @@ +import { RiFundsLine } from "@remixicon/react"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import type { GoalProgressItem } from "@/lib/dashboard/goals-progress"; +import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item"; + +type GoalsProgressListProps = { + items: GoalProgressItem[]; + onEdit: (item: GoalProgressItem) => void; +}; + +export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) { + if (items.length === 0) { + return ( + } + title="Nenhum orçamento para o período" + description="Cadastre orçamentos para acompanhar o progresso das metas." + /> + ); + } + + return ( +
      + {items.map((item, index) => ( + + ))} +
    + ); +} diff --git a/components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx b/components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx new file mode 100644 index 0000000..12c2917 --- /dev/null +++ b/components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx @@ -0,0 +1,29 @@ +import { BudgetDialog } from "@/components/orcamentos/budget-dialog"; +import type { Budget, BudgetCategory } from "@/components/orcamentos/types"; + +type GoalsProgressWidgetDialogsProps = { + selectedBudget: Budget | null; + editOpen: boolean; + categories: BudgetCategory[]; + defaultPeriod: string; + onEditOpenChange: (open: boolean) => void; +}; + +export function GoalsProgressWidgetDialogs({ + selectedBudget, + editOpen, + categories, + defaultPeriod, + onEditOpenChange, +}: GoalsProgressWidgetDialogsProps) { + return ( + + ); +} diff --git a/components/dashboard/goals-progress/goals-progress-widget-view.tsx b/components/dashboard/goals-progress/goals-progress-widget-view.tsx new file mode 100644 index 0000000..1a711c5 --- /dev/null +++ b/components/dashboard/goals-progress/goals-progress-widget-view.tsx @@ -0,0 +1,41 @@ +import type { Budget, BudgetCategory } from "@/components/orcamentos/types"; +import type { + GoalProgressItem, + GoalsProgressData, +} from "@/lib/dashboard/goals-progress"; +import { GoalsProgressList } from "./goals-progress-list"; +import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs"; + +type GoalsProgressWidgetViewProps = { + data: GoalsProgressData; + selectedBudget: Budget | null; + editOpen: boolean; + categories: BudgetCategory[]; + defaultPeriod: string; + onEdit: (item: GoalProgressItem) => void; + onEditOpenChange: (open: boolean) => void; +}; + +export function GoalsProgressWidgetView({ + data, + selectedBudget, + editOpen, + categories, + defaultPeriod, + onEdit, + onEditOpenChange, +}: GoalsProgressWidgetViewProps) { + return ( +
    + + + +
    + ); +} diff --git a/components/dashboard/income-by-category-widget-with-chart.tsx b/components/dashboard/income-by-category-widget-with-chart.tsx index 2411018..d797c52 100644 --- a/components/dashboard/income-by-category-widget-with-chart.tsx +++ b/components/dashboard/income-by-category-widget-with-chart.tsx @@ -1,331 +1,18 @@ "use client"; -import { - RiArrowDownSFill, - RiArrowUpSFill, - RiExternalLinkLine, - RiListUnordered, - RiPieChart2Line, - RiPieChartLine, - RiWallet3Line, -} from "@remixicon/react"; -import Link from "next/link"; -import { useMemo, useState } from "react"; -import { Pie, PieChart, Tooltip } from "recharts"; -import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; -import MoneyValues from "@/components/money-values"; -import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category"; -import { formatPeriodForUrl } from "@/lib/utils/period"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view"; type IncomeByCategoryWidgetWithChartProps = { data: IncomeByCategoryData; period: string; }; -const formatPercentage = (value: number) => { - return `${Math.abs(value).toFixed(1)}%`; -}; - -const formatCurrency = (value: number) => - new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(value); - export function IncomeByCategoryWidgetWithChart({ data, period, }: IncomeByCategoryWidgetWithChartProps) { - const [activeTab, setActiveTab] = useState<"list" | "chart">("list"); - const periodParam = formatPeriodForUrl(period); - - // Configuração do chart com cores do CSS - const chartConfig = useMemo(() => { - const config: ChartConfig = {}; - const colors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", - "var(--chart-1)", - "var(--chart-2)", - ]; - - if (data.categories.length <= 7) { - data.categories.forEach((category, index) => { - config[category.categoryId] = { - label: category.categoryName, - color: colors[index % colors.length], - }; - }); - } else { - // Top 7 + Outros - const top7 = data.categories.slice(0, 7); - top7.forEach((category, index) => { - config[category.categoryId] = { - label: category.categoryName, - color: colors[index % colors.length], - }; - }); - config.outros = { - label: "Outros", - color: "var(--chart-6)", - }; - } - - return config; - }, [data.categories]); - - // Preparar dados para o gráfico de pizza - Top 7 + Outros - const chartData = useMemo(() => { - if (data.categories.length <= 7) { - return data.categories.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - } - - // Pegar top 7 categorias - const top7 = data.categories.slice(0, 7); - const others = data.categories.slice(7); - - // Somar o restante - const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0); - const othersPercentage = others.reduce( - (sum, cat) => sum + cat.percentageOfTotal, - 0, - ); - - const top7Data = top7.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - - // Adicionar "Outros" se houver - if (others.length > 0) { - top7Data.push({ - category: "outros", - name: "Outros", - value: othersTotal, - percentage: othersPercentage, - fill: chartConfig.outros?.color, - }); - } - - return top7Data; - }, [data.categories, chartConfig]); - - if (data.categories.length === 0) { - return ( - } - title="Nenhuma receita encontrada" - description="Quando houver receitas registradas, elas aparecerão aqui." - /> - ); - } - return ( - setActiveTab(v as "list" | "chart")} - className="w-full" - > -
    - - - - Lista - - - - Gráfico - - -
    - - -
    - {data.categories.map((category, index) => { - const hasIncrease = - category.percentageChange !== null && - category.percentageChange > 0; - const hasDecrease = - category.percentageChange !== null && - category.percentageChange < 0; - const hasBudget = category.budgetAmount !== null; - const budgetExceeded = - hasBudget && - category.budgetUsedPercentage !== null && - category.budgetUsedPercentage > 100; - - const exceededAmount = - budgetExceeded && category.budgetAmount - ? category.currentAmount - category.budgetAmount - : 0; - - return ( -
    -
    -
    - - -
    -
    - - - {category.categoryName} - - - -
    -
    - - {formatPercentage(category.percentageOfTotal)} da - receita total - -
    -
    -
    - -
    - - {category.percentageChange !== null && ( - - {hasIncrease && } - {hasDecrease && } - {formatPercentage(category.percentageChange)} - - )} -
    -
    - - {hasBudget && - category.budgetUsedPercentage !== null && - category.budgetAmount !== null && ( -
    - - - {budgetExceeded ? ( - <> - {formatPercentage(category.budgetUsedPercentage)} do - limite {formatCurrency(category.budgetAmount)} - - excedeu em {formatCurrency(exceededAmount)} - - ) : ( - <> - {formatPercentage(category.budgetUsedPercentage)} do - limite {formatCurrency(category.budgetAmount)} - - )} - -
    - )} -
    - ); - })} -
    -
    - - -
    - - - formatPercentage(entry.percentage)} - outerRadius={75} - dataKey="value" - nameKey="category" - /> - { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
    -
    -
    - - {data.name} - - - {formatCurrency(data.value)} - - - {formatPercentage(data.percentage)} do total - -
    -
    -
    - ); - } - return null; - }} - /> -
    -
    - -
    - {chartData.map((entry, index) => ( -
    -
    - - {entry.name} - -
    - ))} -
    -
    - - + ); } diff --git a/components/dashboard/income-expense-balance-widget.tsx b/components/dashboard/income-expense-balance-widget.tsx index 9548225..275cfb5 100644 --- a/components/dashboard/income-expense-balance-widget.tsx +++ b/components/dashboard/income-expense-balance-widget.tsx @@ -2,14 +2,15 @@ import { RiLineChartLine } from "@remixicon/react"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import { CardContent } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip, } from "@/components/ui/chart"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance"; +import { formatCurrency } from "@/lib/utils/currency"; type IncomeExpenseBalanceWidgetProps = { data: IncomeExpenseBalanceData; @@ -80,15 +81,6 @@ export function IncomeExpenseBalanceWidget({ return null; } - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); - }; - return (
    @@ -103,7 +95,7 @@ export function IncomeExpenseBalanceWidget({ className="flex items-center gap-2" >
    @@ -144,7 +136,7 @@ export function IncomeExpenseBalanceWidget({
    @@ -153,7 +145,7 @@ export function IncomeExpenseBalanceWidget({
    @@ -162,7 +154,7 @@ export function IncomeExpenseBalanceWidget({
    diff --git a/components/dashboard/installment-analysis/installment-analysis-page.tsx b/components/dashboard/installment-analysis/installment-analysis-page.tsx index 0cb6d3d..514a2ec 100644 --- a/components/dashboard/installment-analysis/installment-analysis-page.tsx +++ b/components/dashboard/installment-analysis/installment-analysis-page.tsx @@ -6,7 +6,7 @@ import { RiCheckboxLine, } from "@remixicon/react"; import { useMemo, useState } from "react"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { InstallmentGroupCard } from "./installment-group-card"; diff --git a/components/dashboard/installment-analysis/installment-group-card.tsx b/components/dashboard/installment-analysis/installment-group-card.tsx index f17ae7a..429a1cc 100644 --- a/components/dashboard/installment-analysis/installment-group-card.tsx +++ b/components/dashboard/installment-analysis/installment-group-card.tsx @@ -8,7 +8,7 @@ import { import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; import { useState } from "react"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/components/dashboard/installment-expenses-widget.tsx b/components/dashboard/installment-expenses-widget.tsx index fc46ea9..c5d4be3 100644 --- a/components/dashboard/installment-expenses-widget.tsx +++ b/components/dashboard/installment-expenses-widget.tsx @@ -1,191 +1,12 @@ -import { RiNumbersLine } from "@remixicon/react"; -import Image from "next/image"; -import MoneyValues from "@/components/money-values"; -import { CardContent } from "@/components/ui/card"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses"; -import { - calculateLastInstallmentDate, - formatLastInstallmentDate, -} from "@/lib/installments/utils"; -import { Progress } from "../ui/progress"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view"; type InstallmentExpensesWidgetProps = { data: InstallmentExpensesData; }; -const buildCompactInstallmentLabel = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (currentInstallment && installmentCount) { - return `${currentInstallment} de ${installmentCount}`; - } - return null; -}; - -const isLastInstallment = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount) return false; - return currentInstallment === installmentCount && installmentCount > 1; -}; - -const calculateRemainingInstallments = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount) return 0; - return Math.max(0, installmentCount - currentInstallment); -}; - -const calculateRemainingAmount = ( - amount: number, - currentInstallment: number | null, - installmentCount: number | null, -) => { - const remaining = calculateRemainingInstallments( - currentInstallment, - installmentCount, - ); - return amount * remaining; -}; - -const formatEndDate = ( - period: string, - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount) return null; - - const lastDate = calculateLastInstallmentDate( - period, - currentInstallment, - installmentCount, - ); - - return formatLastInstallmentDate(lastDate); -}; - -const buildProgress = ( - currentInstallment: number | null, - installmentCount: number | null, -) => { - if (!currentInstallment || !installmentCount || installmentCount <= 0) { - return 0; - } - - return Math.min( - 100, - Math.max(0, (currentInstallment / installmentCount) * 100), - ); -}; - export function InstallmentExpensesWidget({ data, }: InstallmentExpensesWidgetProps) { - if (data.expenses.length === 0) { - return ( - } - title="Nenhuma despesa parcelada" - description="Lançamentos parcelados aparecerão aqui conforme forem registrados." - /> - ); - } - - return ( - -
      - {data.expenses.map((expense) => { - const compactLabel = buildCompactInstallmentLabel( - expense.currentInstallment, - expense.installmentCount, - ); - const isLast = isLastInstallment( - expense.currentInstallment, - expense.installmentCount, - ); - const remainingInstallments = calculateRemainingInstallments( - expense.currentInstallment, - expense.installmentCount, - ); - const remainingAmount = calculateRemainingAmount( - expense.amount, - expense.currentInstallment, - expense.installmentCount, - ); - const endDate = formatEndDate( - expense.period, - expense.currentInstallment, - expense.installmentCount, - ); - const progress = buildProgress( - expense.currentInstallment, - expense.installmentCount, - ); - - return ( -
    • -
      -
      -
      -

      - {expense.name} -

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

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

      - - -
      -
    • - ); - })} -
    -
    - ); + return ; } diff --git a/components/dashboard/installment-expenses/installment-expense-list-item.tsx b/components/dashboard/installment-expenses/installment-expense-list-item.tsx new file mode 100644 index 0000000..c931eea --- /dev/null +++ b/components/dashboard/installment-expenses/installment-expense-list-item.tsx @@ -0,0 +1,76 @@ +import Image from "next/image"; +import MoneyValues from "@/components/shared/money-values"; +import { Progress } from "@/components/ui/progress"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses"; +import { buildInstallmentExpenseDisplay } from "@/lib/dashboard/installment-expenses-helpers"; + +type InstallmentExpenseListItemProps = { + expense: InstallmentExpense; +}; + +export function InstallmentExpenseListItem({ + expense, +}: InstallmentExpenseListItemProps) { + const { + compactLabel, + isLast, + remainingInstallments, + remainingAmount, + endDate, + progress, + } = buildInstallmentExpenseDisplay(expense); + + return ( +
  • +
    +
    +
    +

    + {expense.name} +

    + {compactLabel ? ( + + {compactLabel} + {isLast ? ( + + + + Última parcela + Última parcela + + + Última parcela! + + ) : null} + + ) : null} +
    + +
    + +

    + {endDate ? `Termina em ${endDate}` : null} + {" | Restante "} + {" "} + ({remainingInstallments}) +

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

      - Distribuição por pagador -

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

        - {share.pagadorName} -

        -

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

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

    - Cartão -

    -

    - {selectedInvoice.cardName} -

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

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

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

    - {selectedPaymentInfo.label} -

    - ) : null} -
    -
    -
    - -
    -
    -
    - - - Valor da Fatura - -
    - -
    -
    -
    - - - Status - -
    - - {INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]} - -
    -
    -
    - ) : null} - - - - - - - )} -
    -
    - + ); } diff --git a/components/dashboard/invoices/invoice-list-item.tsx b/components/dashboard/invoices/invoice-list-item.tsx new file mode 100644 index 0000000..0c4e11d --- /dev/null +++ b/components/dashboard/invoices/invoice-list-item.tsx @@ -0,0 +1,148 @@ +import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react"; +import Link from "next/link"; +import MoneyValues from "@/components/shared/money-values"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import type { DashboardInvoice } from "@/lib/dashboard/invoices"; +import { + buildInvoiceDetailsHref, + buildInvoiceInitials, + formatInvoicePaymentDate, + getInvoiceShareLabel, + parseInvoiceDueDate, +} from "@/lib/dashboard/invoices-helpers"; +import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas"; +import { getAvatarSrc } from "@/lib/pagadores/utils"; +import { isDateOnlyPast } from "@/lib/utils/date"; +import { InvoiceLogo } from "./invoice-logo"; + +type InvoiceListItemProps = { + invoice: DashboardInvoice; + onPay: (invoiceId: string) => void; +}; + +export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) { + const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay); + const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID; + const isOverdue = + !isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date); + const paymentInfo = formatInvoicePaymentDate(invoice.paidAt); + const breakdown = invoice.pagadorBreakdown ?? []; + const hasBreakdown = breakdown.length > 0; + const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period); + + const linkNode = ( + + {invoice.cardName} + + + ); + + return ( +
  • +
    + + +
    + {hasBreakdown ? ( + + {linkNode} + +

    + Distribuição por pagador +

    +
      + {breakdown.map((share, index) => ( +
    • + + + + {buildInvoiceInitials(share.pagadorName)} + + +
      +

      + {share.pagadorName} +

      +

      + {getInvoiceShareLabel( + share.amount, + Math.abs(invoice.totalAmount), + )} +

      +
      +
      + +
      +
    • + ))} +
    +
    +
    + ) : ( + linkNode + )} + +
    + {!isPaid ? {dueInfo.label} : null} + {isPaid && paymentInfo ? ( + {paymentInfo.label} + ) : null} +
    +
    +
    + +
    + + +
    +
  • + ); +} diff --git a/components/dashboard/invoices/invoice-logo.tsx b/components/dashboard/invoices/invoice-logo.tsx new file mode 100644 index 0000000..c270a51 --- /dev/null +++ b/components/dashboard/invoices/invoice-logo.tsx @@ -0,0 +1,59 @@ +import Image from "next/image"; +import { + buildInvoiceInitials, + type InvoiceLogoTone, +} from "@/lib/dashboard/invoices-helpers"; +import { resolveLogoSrc } from "@/lib/logo"; +import { cn } from "@/lib/utils/ui"; + +type InvoiceLogoProps = { + cardName: string; + logo: string | null; + size: number; + containerClassName?: string; + imageClassName?: string; + fallbackClassName?: string; + tone?: InvoiceLogoTone; +}; + +export function InvoiceLogo({ + cardName, + logo, + size, + containerClassName, + imageClassName, + fallbackClassName, + tone = "muted", +}: InvoiceLogoProps) { + const resolvedLogo = resolveLogoSrc(logo); + + return ( +
    + {resolvedLogo ? ( + {`Logo + ) : ( + + {buildInvoiceInitials(cardName)} + + )} +
    + ); +} diff --git a/components/dashboard/invoices/invoice-payment-dialog.tsx b/components/dashboard/invoices/invoice-payment-dialog.tsx new file mode 100644 index 0000000..8e732c1 --- /dev/null +++ b/components/dashboard/invoices/invoice-payment-dialog.tsx @@ -0,0 +1,203 @@ +import { + RiCheckboxCircleLine, + RiLoader4Line, + RiMoneyDollarCircleLine, +} from "@remixicon/react"; +import MoneyValues from "@/components/shared/money-values"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { DashboardInvoice } from "@/lib/dashboard/invoices"; +import { + formatInvoicePaymentDate, + getInvoiceStatusBadgeVariant, + type InvoiceDialogState, + parseInvoiceDueDate, +} from "@/lib/dashboard/invoices-helpers"; +import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas"; +import { InvoiceLogo } from "./invoice-logo"; + +type InvoicePaymentDialogProps = { + invoice: DashboardInvoice | null; + open: boolean; + modalState: InvoiceDialogState; + isPending: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export function InvoicePaymentDialog({ + invoice, + open, + modalState, + isPending, + onClose, + onConfirm, +}: InvoicePaymentDialogProps) { + const isProcessing = modalState === "processing" || isPending; + const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null; + + return ( + { + if (nextOpen || isProcessing) { + return; + } + onClose(); + }} + > + { + if (isProcessing) { + event.preventDefault(); + } + }} + onPointerDownOutside={(event) => { + if (isProcessing) { + event.preventDefault(); + } + }} + > + {modalState === "success" ? ( +
    +
    + +
    +
    + + Pagamento confirmado! + + + Atualizamos o status da fatura. O lançamento do pagamento + aparecerá no extrato em instantes. + +
    + + + +
    + ) : ( + <> + + Confirmar pagamento + + Revise os dados antes de confirmar. Vamos registrar a fatura + como paga. + + + + {invoice ? ( +
    +
    +
    +
    + +
    +

    + Cartão +

    +

    + {invoice.cardName} +

    +
    +
    +
    + {invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? ( +

    + { + parseInvoiceDueDate(invoice.period, invoice.dueDay) + .label + } +

    + ) : null} + {invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID && + paymentInfo ? ( +

    + {paymentInfo.label} +

    + ) : null} +
    +
    +
    + +
    +
    +
    + + + Valor da Fatura + +
    + +
    +
    +
    + + + Status + +
    + + {INVOICE_STATUS_LABEL[invoice.paymentStatus]} + +
    +
    +
    + ) : null} + + + + + + + )} +
    +
    + ); +} diff --git a/components/dashboard/invoices/invoices-list.tsx b/components/dashboard/invoices/invoices-list.tsx new file mode 100644 index 0000000..cb9106d --- /dev/null +++ b/components/dashboard/invoices/invoices-list.tsx @@ -0,0 +1,29 @@ +import { RiBillLine } from "@remixicon/react"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import type { DashboardInvoice } from "@/lib/dashboard/invoices"; +import { InvoiceListItem } from "./invoice-list-item"; + +type InvoicesListProps = { + invoices: DashboardInvoice[]; + onPay: (invoiceId: string) => void; +}; + +export function InvoicesList({ invoices, onPay }: InvoicesListProps) { + if (invoices.length === 0) { + return ( + } + title="Nenhuma fatura para o período selecionado" + description="Quando houver cartões com compras registradas, eles aparecerão aqui." + /> + ); + } + + return ( +
      + {invoices.map((invoice) => ( + + ))} +
    + ); +} diff --git a/components/dashboard/invoices/invoices-widget-view.tsx b/components/dashboard/invoices/invoices-widget-view.tsx new file mode 100644 index 0000000..6623be6 --- /dev/null +++ b/components/dashboard/invoices/invoices-widget-view.tsx @@ -0,0 +1,43 @@ +import type { DashboardInvoice } from "@/lib/dashboard/invoices"; +import type { InvoiceDialogState } from "@/lib/dashboard/invoices-helpers"; +import { InvoicePaymentDialog } from "./invoice-payment-dialog"; +import { InvoicesList } from "./invoices-list"; + +type InvoicesWidgetViewProps = { + invoices: DashboardInvoice[]; + selectedInvoice: DashboardInvoice | null; + isModalOpen: boolean; + modalState: InvoiceDialogState; + isPending: boolean; + onOpenPaymentDialog: (invoiceId: string) => void; + onClosePaymentDialog: () => void; + onConfirmPayment: () => void; +}; + +export function InvoicesWidgetView({ + invoices, + selectedInvoice, + isModalOpen, + modalState, + isPending, + onOpenPaymentDialog, + onClosePaymentDialog, + onConfirmPayment, +}: InvoicesWidgetViewProps) { + return ( + <> +
    + +
    + + + + ); +} diff --git a/components/dashboard/my-accounts-widget.tsx b/components/dashboard/my-accounts-widget.tsx index 6ab3c4d..0b57ea4 100644 --- a/components/dashboard/my-accounts-widget.tsx +++ b/components/dashboard/my-accounts-widget.tsx @@ -1,62 +1,38 @@ import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react"; import Image from "next/image"; import Link from "next/link"; -import { - CardContent, - CardDescription, - CardFooter, - CardHeader, -} from "@/components/ui/card"; +import { CardFooter } from "@/components/ui/card"; import type { DashboardAccount } from "@/lib/dashboard/accounts"; +import { resolveLogoSrc } from "@/lib/logo"; import { formatPeriodForUrl } from "@/lib/utils/period"; -import MoneyValues from "../money-values"; -import { WidgetEmptyState } from "../widget-empty-state"; +import MoneyValues from "@/components/shared/money-values"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; type MyAccountsWidgetProps = { accounts: DashboardAccount[]; totalBalance: number; - maxVisible?: number; period: string; }; -const resolveLogoSrc = (logo: string | null) => { - if (!logo) { - return null; - } - - const fileName = logo.split("/").filter(Boolean).pop() ?? logo; - return `/logos/${fileName}`; -}; - -const buildInitials = (name: string) => { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "CC"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase(); -}; - export function MyAccountsWidget({ accounts, totalBalance, - maxVisible = 5, period, }: MyAccountsWidgetProps) { const visibleAccounts = accounts.filter( (account) => !account.excludeFromBalance, ); - const displayedAccounts = visibleAccounts.slice(0, maxVisible); + const displayedAccounts = visibleAccounts.slice(0, 5); const remainingCount = visibleAccounts.length - displayedAccounts.length; return ( <> - - Saldo Total -
    - -
    -
    +
    + Saldo Total + +
    - +
    {displayedAccounts.length === 0 ? (
    {displayedAccounts.map((account) => { const logoSrc = resolveLogoSrc(account.logo); - const initials = buildInitials(account.name); return (
  • - {logoSrc ? ( -
    - {`Logo -
    - ) : ( -
    - {initials} -
    - )} +
    + {`Logo +
    )} - +
    {visibleAccounts.length > displayedAccounts.length ? ( diff --git a/components/dashboard/notes-widget.tsx b/components/dashboard/notes-widget.tsx index f982e81..accf53e 100644 --- a/components/dashboard/notes-widget.tsx +++ b/components/dashboard/notes-widget.tsx @@ -1,154 +1,37 @@ "use client"; -import { RiFileList2Line, RiPencilLine, RiTodoLine } from "@remixicon/react"; -import { useCallback, useMemo, useState } from "react"; -import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog"; -import { NoteDialog } from "@/components/anotacoes/note-dialog"; -import type { Note } from "@/components/anotacoes/types"; -import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; import type { DashboardNote } from "@/lib/dashboard/notes"; -import { Badge } from "../ui/badge"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { useNotesWidgetController } from "@/lib/dashboard/use-notes-widget-controller"; +import { NotesWidgetView } from "./notes/notes-widget-view"; type NotesWidgetProps = { notes: DashboardNote[]; }; -const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - timeZone: "UTC", -}); - -const buildDisplayTitle = (value: string) => { - const trimmed = value.trim(); - return trimmed.length ? trimmed : "Anotação sem título"; -}; - -const mapDashboardNoteToNote = (note: DashboardNote): Note => ({ - id: note.id, - title: note.title, - description: note.description, - type: note.type, - tasks: note.tasks, - arquivada: note.arquivada, - createdAt: note.createdAt, -}); - -const getTasksSummary = (note: DashboardNote) => { - if (note.type !== "tarefa") { - return "Nota"; - } - - const tasks = note.tasks ?? []; - const completed = tasks.filter((task) => task.completed).length; - return `${completed}/${tasks.length} concluídas`; -}; - export function NotesWidget({ notes }: NotesWidgetProps) { - const [noteToEdit, setNoteToEdit] = useState(null); - const [isEditOpen, setIsEditOpen] = useState(false); - const [noteDetails, setNoteDetails] = useState(null); - const [isDetailsOpen, setIsDetailsOpen] = useState(false); - - const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]); - - const handleOpenEdit = useCallback((note: Note) => { - setNoteToEdit(note); - setIsEditOpen(true); - }, []); - - const handleOpenDetails = useCallback((note: Note) => { - setNoteDetails(note); - setIsDetailsOpen(true); - }, []); - - const handleEditOpenChange = useCallback((open: boolean) => { - setIsEditOpen(open); - if (!open) { - setNoteToEdit(null); - } - }, []); - - const handleDetailsOpenChange = useCallback((open: boolean) => { - setIsDetailsOpen(open); - if (!open) { - setNoteDetails(null); - } - }, []); + const { + mappedNotes, + noteToEdit, + isEditOpen, + noteDetails, + isDetailsOpen, + openEdit, + openDetails, + handleEditOpenChange, + handleDetailsOpenChange, + } = useNotesWidgetController(notes); return ( - <> - - {mappedNotes.length === 0 ? ( - } - title="Nenhuma anotação ativa" - description="Crie anotações para acompanhar lembretes e tarefas financeiras." - /> - ) : ( -
      - {mappedNotes.map((note) => ( -
    • -
      -

      - {buildDisplayTitle(note.title)} -

      -
      - - {getTasksSummary(note)} - -

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

      -
      -
      - -
      - - -
      -
    • - ))} -
    - )} -
    - - - - - + ); } diff --git a/components/dashboard/notes/note-list-item.tsx b/components/dashboard/notes/note-list-item.tsx new file mode 100644 index 0000000..85aad79 --- /dev/null +++ b/components/dashboard/notes/note-list-item.tsx @@ -0,0 +1,65 @@ +import { RiFileList2Line, RiPencilLine } from "@remixicon/react"; +import type { Note } from "@/components/anotacoes/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + buildNoteDisplayTitle, + formatNoteCreatedAt, + getNoteTasksSummary, +} from "@/lib/notes/formatters"; + +type NoteListItemProps = { + note: Note; + onOpenEdit: (note: Note) => void; + onOpenDetails: (note: Note) => void; +}; + +export function NoteListItem({ + note, + onOpenEdit, + onOpenDetails, +}: NoteListItemProps) { + const displayTitle = buildNoteDisplayTitle(note.title); + const createdAtLabel = formatNoteCreatedAt(note.createdAt); + + return ( +
  • +
    +

    + {displayTitle} +

    +
    + + {getNoteTasksSummary(note)} + + {createdAtLabel ? ( +

    + {createdAtLabel} +

    + ) : null} +
    +
    + +
    + + +
    +
  • + ); +} diff --git a/components/dashboard/notes/notes-list.tsx b/components/dashboard/notes/notes-list.tsx new file mode 100644 index 0000000..f72e832 --- /dev/null +++ b/components/dashboard/notes/notes-list.tsx @@ -0,0 +1,39 @@ +import { RiTodoLine } from "@remixicon/react"; +import type { Note } from "@/components/anotacoes/types"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import { NoteListItem } from "./note-list-item"; + +type NotesListProps = { + notes: Note[]; + onOpenEdit: (note: Note) => void; + onOpenDetails: (note: Note) => void; +}; + +export function NotesList({ + notes, + onOpenEdit, + onOpenDetails, +}: NotesListProps) { + if (notes.length === 0) { + return ( + } + title="Nenhuma anotação ativa" + description="Crie anotações para acompanhar lembretes e tarefas financeiras." + /> + ); + } + + return ( +
      + {notes.map((note) => ( + + ))} +
    + ); +} diff --git a/components/dashboard/notes/notes-widget-dialogs.tsx b/components/dashboard/notes/notes-widget-dialogs.tsx new file mode 100644 index 0000000..e0c7686 --- /dev/null +++ b/components/dashboard/notes/notes-widget-dialogs.tsx @@ -0,0 +1,38 @@ +import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog"; +import { NoteDialog } from "@/components/anotacoes/note-dialog"; +import type { Note } from "@/components/anotacoes/types"; + +type NotesWidgetDialogsProps = { + noteToEdit: Note | null; + isEditOpen: boolean; + noteDetails: Note | null; + isDetailsOpen: boolean; + onEditOpenChange: (open: boolean) => void; + onDetailsOpenChange: (open: boolean) => void; +}; + +export function NotesWidgetDialogs({ + noteToEdit, + isEditOpen, + noteDetails, + isDetailsOpen, + onEditOpenChange, + onDetailsOpenChange, +}: NotesWidgetDialogsProps) { + return ( + <> + + + + + ); +} diff --git a/components/dashboard/notes/notes-widget-view.tsx b/components/dashboard/notes/notes-widget-view.tsx new file mode 100644 index 0000000..07c67f3 --- /dev/null +++ b/components/dashboard/notes/notes-widget-view.tsx @@ -0,0 +1,48 @@ +import type { Note } from "@/components/anotacoes/types"; +import { NotesList } from "./notes-list"; +import { NotesWidgetDialogs } from "./notes-widget-dialogs"; + +type NotesWidgetViewProps = { + notes: Note[]; + noteToEdit: Note | null; + isEditOpen: boolean; + noteDetails: Note | null; + isDetailsOpen: boolean; + onOpenEdit: (note: Note) => void; + onOpenDetails: (note: Note) => void; + onEditOpenChange: (open: boolean) => void; + onDetailsOpenChange: (open: boolean) => void; +}; + +export function NotesWidgetView({ + notes, + noteToEdit, + isEditOpen, + noteDetails, + isDetailsOpen, + onOpenEdit, + onOpenDetails, + onEditOpenChange, + onDetailsOpenChange, +}: NotesWidgetViewProps) { + return ( + <> +
    + +
    + + + + ); +} diff --git a/components/dashboard/pagadores-widget.tsx b/components/dashboard/payers-widget.tsx similarity index 92% rename from components/dashboard/pagadores-widget.tsx rename to components/dashboard/payers-widget.tsx index afe22fa..30154ac 100644 --- a/components/dashboard/pagadores-widget.tsx +++ b/components/dashboard/payers-widget.tsx @@ -8,21 +8,18 @@ import { RiVerifiedBadgeFill, } from "@remixicon/react"; import Link from "next/link"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { CardContent } from "@/components/ui/card"; import type { DashboardPagador } from "@/lib/dashboard/pagadores"; import { getAvatarSrc } from "@/lib/pagadores/utils"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { formatPercentage } from "@/lib/utils/percentage"; -type PagadoresWidgetProps = { +type PayersWidgetProps = { pagadores: DashboardPagador[]; }; -const formatPercentage = (value: number) => { - return `${Math.abs(value).toFixed(0)}%`; -}; - const buildInitials = (value: string) => { const parts = value.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) { @@ -37,7 +34,7 @@ const buildInitials = (value: string) => { return `${firstChar}${secondChar}`.toUpperCase() || "??"; }; -export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) { +export function PayersWidget({ pagadores }: PayersWidgetProps) { return ( {pagadores.length === 0 ? ( diff --git a/components/dashboard/payment-conditions-widget.tsx b/components/dashboard/payment-conditions-widget.tsx deleted file mode 100644 index 558180f..0000000 --- a/components/dashboard/payment-conditions-widget.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { - RiCheckLine, - RiLoader2Fill, - RiRefreshLine, - RiSlideshowLine, -} from "@remixicon/react"; -import type { ReactNode } from "react"; -import MoneyValues from "@/components/money-values"; -import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; -import { Progress } from "../ui/progress"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type PaymentConditionsWidgetProps = { - data: PaymentConditionsData; -}; - -const CONDITION_ICON_CLASSES = - "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"; - -const CONDITION_ICONS: Record = { - "À vista": , - Parcelado: , - Recorrente: , -}; - -const formatPercentage = (value: number) => - new Intl.NumberFormat("pt-BR", { - minimumFractionDigits: 0, - maximumFractionDigits: 1, - }).format(value); - -export function PaymentConditionsWidget({ - data, -}: PaymentConditionsWidgetProps) { - if (data.conditions.length === 0) { - return ( - } - title="Nenhuma despesa encontrada" - description="As distribuições por condição aparecerão conforme novos lançamentos." - /> - ); - } - - return ( -
    -
      - {data.conditions.map((condition) => { - const Icon = - CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"]; - const percentageLabel = formatPercentage(condition.percentage); - - return ( -
    • -
      {Icon}
      - -
      -
      -

      - {condition.condition} -

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

      - {method.paymentMethod} -

      - -
      - -
      - - {method.transactions}{" "} - {method.transactions === 1 ? "lançamento" : "lançamentos"} - - {percentageLabel}% -
      - -
      - -
      -
      -
    • - ); - })} -
    -
    - ); -} diff --git a/components/dashboard/payment-overview-widget.tsx b/components/dashboard/payment-overview-widget.tsx index e9bd20c..7e69e46 100644 --- a/components/dashboard/payment-overview-widget.tsx +++ b/components/dashboard/payment-overview-widget.tsx @@ -1,12 +1,9 @@ "use client"; -import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react"; -import { useState } from "react"; import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { PaymentConditionsWidget } from "./payment-conditions-widget"; -import { PaymentMethodsWidget } from "./payment-methods-widget"; +import { usePaymentOverviewWidgetController } from "@/lib/dashboard/use-payment-overview-widget-controller"; +import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view"; type PaymentOverviewWidgetProps = { paymentConditionsData: PaymentConditionsData; @@ -17,34 +14,14 @@ export function PaymentOverviewWidget({ paymentConditionsData, paymentMethodsData, }: PaymentOverviewWidgetProps) { - const [activeTab, setActiveTab] = useState<"conditions" | "methods">( - "conditions", - ); + const { activeTab, handleTabChange } = usePaymentOverviewWidgetController(); return ( - setActiveTab(value as "conditions" | "methods")} - className="w-full" - > - - - - Condições - - - - Formas - - - - - - - - - - - + ); } diff --git a/components/dashboard/payment-overview/payment-breakdown-list-item.tsx b/components/dashboard/payment-overview/payment-breakdown-list-item.tsx new file mode 100644 index 0000000..b50cde9 --- /dev/null +++ b/components/dashboard/payment-overview/payment-breakdown-list-item.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import MoneyValues from "@/components/shared/money-values"; +import { Progress } from "@/components/ui/progress"; +import { + formatPaymentBreakdownPercentage, + formatPaymentBreakdownTransactionsLabel, +} from "@/lib/dashboard/payment-breakdown-formatters"; + +const ICON_WRAPPER_CLASS = + "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"; + +export type PaymentBreakdownListItemData = { + id: string; + title: string; + icon: ReactNode; + amount: number; + transactions: number; + percentage: number; +}; + +type PaymentBreakdownListItemProps = { + item: PaymentBreakdownListItemData; +}; + +export function PaymentBreakdownListItem({ + item, +}: PaymentBreakdownListItemProps) { + return ( +
  • +
    {item.icon}
    + +
    +
    +

    {item.title}

    + +
    + +
    + + {formatPaymentBreakdownTransactionsLabel(item.transactions)} + + {formatPaymentBreakdownPercentage(item.percentage)} +
    + +
    + +
    +
    +
  • + ); +} diff --git a/components/dashboard/payment-overview/payment-breakdown-list.tsx b/components/dashboard/payment-overview/payment-breakdown-list.tsx new file mode 100644 index 0000000..edd4dd2 --- /dev/null +++ b/components/dashboard/payment-overview/payment-breakdown-list.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import { + PaymentBreakdownListItem, + type PaymentBreakdownListItemData, +} from "./payment-breakdown-list-item"; + +export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item"; + +type PaymentBreakdownListProps = { + items: PaymentBreakdownListItemData[]; + emptyIcon: ReactNode; + emptyTitle: string; + emptyDescription: string; +}; + +export function PaymentBreakdownList({ + items, + emptyIcon, + emptyTitle, + emptyDescription, +}: PaymentBreakdownListProps) { + if (items.length === 0) { + return ( + + ); + } + + return ( +
    +
      + {items.map((item) => ( + + ))} +
    +
    + ); +} diff --git a/components/dashboard/payment-overview/payment-conditions-widget.tsx b/components/dashboard/payment-overview/payment-conditions-widget.tsx new file mode 100644 index 0000000..efb3c63 --- /dev/null +++ b/components/dashboard/payment-overview/payment-conditions-widget.tsx @@ -0,0 +1,38 @@ +import { RiCheckLine, RiSlideshowLine } from "@remixicon/react"; +import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; +import { getConditionIcon } from "@/lib/utils/icons"; +import { + PaymentBreakdownList, + type PaymentBreakdownListItemData, +} from "./payment-breakdown-list"; + +type PaymentConditionsWidgetProps = { + data: PaymentConditionsData; +}; + +const resolveConditionIcon = (condition: string) => + getConditionIcon(condition) ?? ; + +export function PaymentConditionsWidget({ + data, +}: PaymentConditionsWidgetProps) { + const items: PaymentBreakdownListItemData[] = data.conditions.map( + (condition) => ({ + id: condition.condition, + title: condition.condition, + icon: resolveConditionIcon(condition.condition), + amount: condition.amount, + transactions: condition.transactions, + percentage: condition.percentage, + }), + ); + + return ( + } + emptyTitle="Nenhuma despesa encontrada" + emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos." + /> + ); +} diff --git a/components/dashboard/payment-overview/payment-methods-widget.tsx b/components/dashboard/payment-overview/payment-methods-widget.tsx new file mode 100644 index 0000000..83769bd --- /dev/null +++ b/components/dashboard/payment-overview/payment-methods-widget.tsx @@ -0,0 +1,38 @@ +import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react"; +import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods"; +import { getPaymentMethodIcon } from "@/lib/utils/icons"; +import { + PaymentBreakdownList, + type PaymentBreakdownListItemData, +} from "./payment-breakdown-list"; + +type PaymentMethodsWidgetProps = { + data: PaymentMethodsData; +}; + +const resolvePaymentMethodIcon = (paymentMethod: string) => + getPaymentMethodIcon(paymentMethod) ?? ( + + ); + +export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) { + const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({ + id: method.paymentMethod, + title: method.paymentMethod, + icon: resolvePaymentMethodIcon(method.paymentMethod), + amount: method.amount, + transactions: method.transactions, + percentage: method.percentage, + })); + + return ( + + } + emptyTitle="Nenhuma despesa encontrada" + emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento." + /> + ); +} diff --git a/components/dashboard/payment-overview/payment-overview-widget-view.tsx b/components/dashboard/payment-overview/payment-overview-widget-view.tsx new file mode 100644 index 0000000..59328e5 --- /dev/null +++ b/components/dashboard/payment-overview/payment-overview-widget-view.tsx @@ -0,0 +1,44 @@ +import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { PaymentOverviewTab } from "@/lib/dashboard/payment-overview-tabs"; +import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; +import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods"; +import { PaymentConditionsWidget } from "./payment-conditions-widget"; +import { PaymentMethodsWidget } from "./payment-methods-widget"; + +type PaymentOverviewWidgetViewProps = { + activeTab: PaymentOverviewTab; + paymentConditionsData: PaymentConditionsData; + paymentMethodsData: PaymentMethodsData; + onTabChange: (value: string) => void; +}; + +export function PaymentOverviewWidgetView({ + activeTab, + paymentConditionsData, + paymentMethodsData, + onTabChange, +}: PaymentOverviewWidgetViewProps) { + return ( + + + + + Condições + + + + Formas + + + + + + + + + + + + ); +} diff --git a/components/dashboard/payment-status-widget.tsx b/components/dashboard/payment-status-widget.tsx index 6a9fba5..622a031 100644 --- a/components/dashboard/payment-status-widget.tsx +++ b/components/dashboard/payment-status-widget.tsx @@ -1,103 +1,12 @@ "use client"; -import { - RiCheckboxCircleLine, - RiHourglass2Line, - RiWallet3Line, -} from "@remixicon/react"; -import MoneyValues from "@/components/money-values"; -import { CardContent } from "@/components/ui/card"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status"; -import { Progress } from "../ui/progress"; +import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view"; type PaymentStatusWidgetProps = { data: PaymentStatusData; }; -type CategorySectionProps = { - title: string; - total: number; - confirmed: number; - pending: number; -}; - -function CategorySection({ - title, - total, - confirmed, - pending, -}: CategorySectionProps) { - // Usa valores absolutos para calcular percentual corretamente - const absTotal = Math.abs(total); - const absConfirmed = Math.abs(confirmed); - const confirmedPercentage = - absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0; - - return ( -
    -
    - {title} - -
    - - {/* Barra de progresso */} - - - {/* Status de confirmados e pendentes */} -
    -
    - - - confirmados -
    - -
    - - - pendentes -
    -
    -
    - ); -} - export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) { - const isEmpty = data.income.total === 0 && data.expenses.total === 0; - - if (isEmpty) { - return ( - - } - title="Nenhum valor a receber ou pagar no período" - description="Registre lançamentos para visualizar os valores confirmados e pendentes." - /> - - ); - } - - return ( - - - - {/* Linha divisória pontilhada */} -
    - - - - ); + return ; } diff --git a/components/dashboard/payment-status/payment-status-category-section.tsx b/components/dashboard/payment-status/payment-status-category-section.tsx new file mode 100644 index 0000000..0225b17 --- /dev/null +++ b/components/dashboard/payment-status/payment-status-category-section.tsx @@ -0,0 +1,50 @@ +import MoneyValues from "@/components/shared/money-values"; +import StatusDot from "@/components/shared/status-dot"; +import { Progress } from "@/components/ui/progress"; + +type PaymentStatusCategorySectionProps = { + title: string; + total: number; + confirmed: number; + pending: number; +}; + +export function PaymentStatusCategorySection({ + title, + total, + confirmed, + pending, +}: PaymentStatusCategorySectionProps) { + const absTotal = Math.abs(total); + const absConfirmed = Math.abs(confirmed); + const confirmedPercentage = + absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0; + + return ( +
    +
    + {title} + +
    + + + +
    +
    + + + confirmados +
    + +
    + + + pendentes +
    +
    +
    + ); +} diff --git a/components/dashboard/payment-status/payment-status-widget-view.tsx b/components/dashboard/payment-status/payment-status-widget-view.tsx new file mode 100644 index 0000000..9692ae2 --- /dev/null +++ b/components/dashboard/payment-status/payment-status-widget-view.tsx @@ -0,0 +1,47 @@ +import { RiWallet3Line } from "@remixicon/react"; +import { CardContent } from "@/components/ui/card"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; +import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status"; +import { PaymentStatusCategorySection } from "./payment-status-category-section"; + +type PaymentStatusWidgetViewProps = { + data: PaymentStatusData; +}; + +export function PaymentStatusWidgetView({ + data, +}: PaymentStatusWidgetViewProps) { + const isEmpty = data.income.total === 0 && data.expenses.total === 0; + + if (isEmpty) { + return ( + + } + title="Nenhum valor a receber ou pagar no período" + description="Registre lançamentos para visualizar os valores confirmados e pendentes." + /> + + ); + } + + return ( + + + +
    + + + + ); +} diff --git a/components/dashboard/purchases-by-category-widget.tsx b/components/dashboard/purchases-by-category-widget.tsx index fabaaf0..edf8ede 100644 --- a/components/dashboard/purchases-by-category-widget.tsx +++ b/components/dashboard/purchases-by-category-widget.tsx @@ -1,9 +1,10 @@ "use client"; import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import { Select, SelectContent, @@ -13,7 +14,6 @@ import { } from "@/components/ui/select"; import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants"; import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category"; -import { WidgetEmptyState } from "../widget-empty-state"; type PurchasesByCategoryWidgetProps = { data: PurchasesByCategoryData; @@ -38,21 +38,11 @@ const STORAGE_KEY = "purchases-by-category-selected"; export function PurchasesByCategoryWidget({ data, }: PurchasesByCategoryWidgetProps) { - // Inicializa com a categoria salva ou a primeira disponível - const [selectedCategoryId, setSelectedCategoryId] = useState(() => { - if (typeof window === "undefined") { - const firstCategory = data.categories[0]; - return firstCategory ? firstCategory.id : ""; - } - - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved && data.categories.some((cat) => cat.id === saved)) { - return saved; - } - - const firstCategory = data.categories[0]; - return firstCategory ? firstCategory.id : ""; - }); + const firstCategoryId = data.categories[0]?.id ?? ""; + const hasRestoredSelectionRef = useRef(false); + const hasPersistedSelectionRef = useRef(false); + const [selectedCategoryId, setSelectedCategoryId] = + useState(firstCategoryId); // Agrupa categorias por tipo const categoriesByType = useMemo(() => { @@ -72,27 +62,52 @@ export function PurchasesByCategoryWidget({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.categories]); - // Salva a categoria selecionada quando mudar + // Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes. useEffect(() => { + if (hasRestoredSelectionRef.current) { + return; + } + + hasRestoredSelectionRef.current = true; + + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved && data.categories.some((cat) => cat.id === saved)) { + setSelectedCategoryId(saved); + return; + } + + setSelectedCategoryId(firstCategoryId); + }, [data.categories, firstCategoryId]); + + // Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem. + useEffect(() => { + if (!hasPersistedSelectionRef.current) { + hasPersistedSelectionRef.current = true; + return; + } + if (selectedCategoryId) { sessionStorage.setItem(STORAGE_KEY, selectedCategoryId); + return; } + + sessionStorage.removeItem(STORAGE_KEY); }, [selectedCategoryId]); // Atualiza a categoria selecionada se ela não existir mais na lista useEffect(() => { + if (!selectedCategoryId && firstCategoryId) { + setSelectedCategoryId(firstCategoryId); + return; + } + if ( selectedCategoryId && !data.categories.some((cat) => cat.id === selectedCategoryId) ) { - const firstCategory = data.categories[0]; - if (firstCategory) { - setSelectedCategoryId(firstCategory.id); - } else { - setSelectedCategoryId(""); - } + setSelectedCategoryId(firstCategoryId); } - }, [data.categories, selectedCategoryId]); + }, [data.categories, firstCategoryId, selectedCategoryId]); const currentTransactions = useMemo(() => { if (!selectedCategoryId) { diff --git a/components/dashboard/recurring-expenses-widget.tsx b/components/dashboard/recurring-expenses-widget.tsx index 2ac6f7c..34f281c 100644 --- a/components/dashboard/recurring-expenses-widget.tsx +++ b/components/dashboard/recurring-expenses-widget.tsx @@ -1,9 +1,8 @@ import { RiRefreshLine } from "@remixicon/react"; import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; -import { CardContent } from "@/components/ui/card"; +import MoneyValues from "@/components/shared/money-values"; import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; type RecurringExpensesWidgetProps = { data: RecurringExpensesData; @@ -31,7 +30,7 @@ export function RecurringExpensesWidget({ } return ( - +
      {data.expenses.map((expense) => { return ( @@ -61,6 +60,6 @@ export function RecurringExpensesWidget({ ); })}
    - +
    ); } diff --git a/components/dashboard/top-establishments-widget.tsx b/components/dashboard/top-establishments-widget.tsx index d2a3241..7f5bdae 100644 --- a/components/dashboard/top-establishments-widget.tsx +++ b/components/dashboard/top-establishments-widget.tsx @@ -1,8 +1,8 @@ import { RiStore2Line } from "@remixicon/react"; import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; type TopEstablishmentsWidgetProps = { data: TopEstablishmentsData; diff --git a/components/dashboard/top-expenses-widget.tsx b/components/dashboard/top-expenses-widget.tsx index 85078de..3f5acb4 100644 --- a/components/dashboard/top-expenses-widget.tsx +++ b/components/dashboard/top-expenses-widget.tsx @@ -3,13 +3,13 @@ import { RiArrowUpDoubleLine } from "@remixicon/react"; import { useMemo, useState } from "react"; import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Switch } from "@/components/ui/switch"; import type { TopExpense, TopExpensesData, } from "@/lib/dashboard/expenses/top-expenses"; -import { WidgetEmptyState } from "../widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; type TopExpensesWidgetProps = { allExpenses: TopExpensesData; diff --git a/components/dashboard/welcome-widget.ts b/components/dashboard/welcome-widget.ts new file mode 100644 index 0000000..2dd3a77 --- /dev/null +++ b/components/dashboard/welcome-widget.ts @@ -0,0 +1,9 @@ +import { + formatBusinessCurrentDate, + getBusinessGreeting, +} from "@/lib/utils/date"; + +export const formatCurrentDate = (date = new Date()) => + formatBusinessCurrentDate(date); + +export const getGreeting = (date = new Date()) => getBusinessGreeting(date); diff --git a/components/magnet-lines.tsx b/components/magnet-lines.tsx deleted file mode 100644 index 1b3be80..0000000 --- a/components/magnet-lines.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import type React from "react"; -import { type CSSProperties, useEffect, useRef } from "react"; - -interface MagnetLinesProps { - rows?: number; - columns?: number; - containerSize?: string; - lineColor?: string; - lineWidth?: string; - lineHeight?: string; - baseAngle?: number; - className?: string; - style?: CSSProperties; - disabled?: boolean; -} - -const MagnetLines: React.FC = ({ - rows = 9, - columns = 9, - containerSize = "80vmin", - lineColor = "#efefef", - lineWidth = "1vmin", - lineHeight = "6vmin", - baseAngle = -10, - className = "", - style = {}, - disabled = false, -}) => { - const containerRef = useRef(null); - - useEffect(() => { - if (disabled) return; - const container = containerRef.current; - if (!container) return; - - const items = container.querySelectorAll("span"); - - const onPointerMove = (pointer: { x: number; y: number }) => { - items.forEach((item) => { - const rect = item.getBoundingClientRect(); - const centerX = rect.x + rect.width / 2; - const centerY = rect.y + rect.height / 2; - - const b = pointer.x - centerX; - const a = pointer.y - centerY; - const c = Math.sqrt(a * a + b * b) || 1; - const r = - ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1); - - item.style.setProperty("--rotate", `${r}deg`); - }); - }; - - const handlePointerMove = (e: PointerEvent) => { - onPointerMove({ x: e.x, y: e.y }); - }; - - window.addEventListener("pointermove", handlePointerMove); - - if (items.length) { - const middleIndex = Math.floor(items.length / 2); - const rect = items[middleIndex].getBoundingClientRect(); - onPointerMove({ x: rect.x, y: rect.y }); - } - - return () => { - window.removeEventListener("pointermove", handlePointerMove); - }; - }, [disabled]); - - // Se magnetlines estiver desabilitado, não renderiza nada - if (disabled) { - return null; - } - - const total = rows * columns; - const spans = Array.from({ length: total }, (_, i) => ( - - )); - - return ( -
    - {spans} -
    - ); -}; - -export default MagnetLines; diff --git a/components/shared/skeletons/section-cards-skeleton.tsx b/components/shared/skeletons/section-cards-skeleton.tsx deleted file mode 100644 index 076106e..0000000 --- a/components/shared/skeletons/section-cards-skeleton.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Card, CardFooter, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -/** - * Skeleton fiel aos cards de métricas do dashboard (SectionCards) - * Mantém o mesmo layout de 4 colunas responsivo - */ -export function SectionCardsSkeleton() { - return ( -
    - {Array.from({ length: 4 }).map((_, index) => ( - - -
    - {/* Título com ícone */} -
    - - -
    - - {/* Valor principal */} - - - {/* Badge de tendência */} - -
    -
    - - - - - -
    - ))} -
    - ); -} diff --git a/db/schema.ts b/db/schema.ts index e7aea38..f4ae871 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -127,7 +127,6 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", { .notNull() .unique() .references(() => user.id, { onDelete: "cascade" }), - disableMagnetlines: boolean("disable_magnetlines").notNull().default(false), extratoNoteAsColumn: boolean("extrato_note_as_column") .notNull() .default(false), diff --git a/drizzle/0018_rainy_epoch.sql b/drizzle/0018_rainy_epoch.sql new file mode 100644 index 0000000..8023229 --- /dev/null +++ b/drizzle/0018_rainy_epoch.sql @@ -0,0 +1 @@ +ALTER TABLE "preferencias_usuario" DROP COLUMN "disable_magnetlines"; \ No newline at end of file diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..66ec7d6 --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,2416 @@ +{ + "id": "853b7f42-7b0e-43a6-8665-918ec5ec6608", + "prevId": "ad4a0401-4eb8-47ab-a18d-a85643544d73", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anotacoes": { + "name": "anotacoes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "titulo": { + "name": "titulo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descricao": { + "name": "descricao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tipo": { + "name": "tipo", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'nota'" + }, + "tasks": { + "name": "tasks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arquivada": { + "name": "arquivada", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "anotacoes_user_id_user_id_fk": { + "name": "anotacoes_user_id_user_id_fk", + "tableFrom": "anotacoes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.antecipacoes_parcelas": { + "name": "antecipacoes_parcelas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "periodo_antecipacao": { + "name": "periodo_antecipacao", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_antecipacao": { + "name": "data_antecipacao", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "parcelas_antecipadas": { + "name": "parcelas_antecipadas", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "valor_total": { + "name": "valor_total", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "qtde_parcelas": { + "name": "qtde_parcelas", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "desconto": { + "name": "desconto", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "lancamento_id": { + "name": "lancamento_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pagador_id": { + "name": "pagador_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "categoria_id": { + "name": "categoria_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "antecipacoes_parcelas_series_id_idx": { + "name": "antecipacoes_parcelas_series_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "antecipacoes_parcelas_user_id_idx": { + "name": "antecipacoes_parcelas_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk": { + "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "lancamentos", + "columnsFrom": [ + "lancamento_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "antecipacoes_parcelas_pagador_id_pagadores_id_fk": { + "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "pagadores", + "columnsFrom": [ + "pagador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "antecipacoes_parcelas_categoria_id_categorias_id_fk": { + "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "categorias", + "columnsFrom": [ + "categoria_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "antecipacoes_parcelas_user_id_user_id_fk": { + "name": "antecipacoes_parcelas_user_id_user_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cartoes": { + "name": "cartoes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dt_fechamento": { + "name": "dt_fechamento", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dt_vencimento": { + "name": "dt_vencimento", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "limite": { + "name": "limite", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "bandeira": { + "name": "bandeira", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conta_id": { + "name": "conta_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cartoes_user_id_status_idx": { + "name": "cartoes_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cartoes_user_id_user_id_fk": { + "name": "cartoes_user_id_user_id_fk", + "tableFrom": "cartoes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cartoes_conta_id_contas_id_fk": { + "name": "cartoes_conta_id_contas_id_fk", + "tableFrom": "cartoes", + "tableTo": "contas", + "columnsFrom": [ + "conta_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categorias": { + "name": "categorias", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tipo": { + "name": "tipo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icone": { + "name": "icone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "categorias_user_id_type_idx": { + "name": "categorias_user_id_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tipo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categorias_user_id_user_id_fk": { + "name": "categorias_user_id_user_id_fk", + "tableFrom": "categorias", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compartilhamentos_pagador": { + "name": "compartilhamentos_pagador", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pagador_id": { + "name": "pagador_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shared_with_user_id": { + "name": "shared_with_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compartilhamentos_pagador_unique": { + "name": "compartilhamentos_pagador_unique", + "columns": [ + { + "expression": "pagador_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_with_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compartilhamentos_pagador_pagador_id_pagadores_id_fk": { + "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk", + "tableFrom": "compartilhamentos_pagador", + "tableTo": "pagadores", + "columnsFrom": [ + "pagador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compartilhamentos_pagador_shared_with_user_id_user_id_fk": { + "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk", + "tableFrom": "compartilhamentos_pagador", + "tableTo": "user", + "columnsFrom": [ + "shared_with_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compartilhamentos_pagador_created_by_user_id_user_id_fk": { + "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk", + "tableFrom": "compartilhamentos_pagador", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contas": { + "name": "contas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tipo_conta": { + "name": "tipo_conta", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saldo_inicial": { + "name": "saldo_inicial", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "excluir_do_saldo": { + "name": "excluir_do_saldo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "excluir_saldo_inicial_receitas": { + "name": "excluir_saldo_inicial_receitas", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contas_user_id_status_idx": { + "name": "contas_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contas_user_id_user_id_fk": { + "name": "contas_user_id_user_id_fk", + "tableFrom": "contas", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.faturas": { + "name": "faturas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status_pagamento": { + "name": "status_pagamento", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "periodo": { + "name": "periodo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cartao_id": { + "name": "cartao_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "faturas_user_id_period_idx": { + "name": "faturas_user_id_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "faturas_cartao_id_period_idx": { + "name": "faturas_cartao_id_period_idx", + "columns": [ + { + "expression": "cartao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "faturas_user_id_user_id_fk": { + "name": "faturas_user_id_user_id_fk", + "tableFrom": "faturas", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "faturas_cartao_id_cartoes_id_fk": { + "name": "faturas_cartao_id_cartoes_id_fk", + "tableFrom": "faturas", + "tableTo": "cartoes", + "columnsFrom": [ + "cartao_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights_salvos": { + "name": "insights_salvos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "insights_salvos_user_period_idx": { + "name": "insights_salvos_user_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_salvos_user_id_user_id_fk": { + "name": "insights_salvos_user_id_user_id_fk", + "tableFrom": "insights_salvos", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lancamentos": { + "name": "lancamentos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "condicao": { + "name": "condicao", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "forma_pagamento": { + "name": "forma_pagamento", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valor": { + "name": "valor", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "data_compra": { + "name": "data_compra", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tipo_transacao": { + "name": "tipo_transacao", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qtde_parcela": { + "name": "qtde_parcela", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "periodo": { + "name": "periodo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parcela_atual": { + "name": "parcela_atual", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "qtde_recorrencia": { + "name": "qtde_recorrencia", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "data_vencimento": { + "name": "data_vencimento", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "dt_pagamento_boleto": { + "name": "dt_pagamento_boleto", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "realizado": { + "name": "realizado", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "dividido": { + "name": "dividido", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "antecipado": { + "name": "antecipado", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "antecipacao_id": { + "name": "antecipacao_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cartao_id": { + "name": "cartao_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conta_id": { + "name": "conta_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "categoria_id": { + "name": "categoria_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pagador_id": { + "name": "pagador_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transfer_id": { + "name": "transfer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "lancamentos_user_id_period_idx": { + "name": "lancamentos_user_id_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_user_id_period_type_idx": { + "name": "lancamentos_user_id_period_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tipo_transacao", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_pagador_id_period_idx": { + "name": "lancamentos_pagador_id_period_idx", + "columns": [ + { + "expression": "pagador_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_user_id_purchase_date_idx": { + "name": "lancamentos_user_id_purchase_date_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "data_compra", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_series_id_idx": { + "name": "lancamentos_series_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_transfer_id_idx": { + "name": "lancamentos_transfer_id_idx", + "columns": [ + { + "expression": "transfer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_user_id_condition_idx": { + "name": "lancamentos_user_id_condition_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "condicao", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_cartao_id_period_idx": { + "name": "lancamentos_cartao_id_period_idx", + "columns": [ + { + "expression": "cartao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk": { + "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk", + "tableFrom": "lancamentos", + "tableTo": "antecipacoes_parcelas", + "columnsFrom": [ + "antecipacao_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lancamentos_user_id_user_id_fk": { + "name": "lancamentos_user_id_user_id_fk", + "tableFrom": "lancamentos", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lancamentos_cartao_id_cartoes_id_fk": { + "name": "lancamentos_cartao_id_cartoes_id_fk", + "tableFrom": "lancamentos", + "tableTo": "cartoes", + "columnsFrom": [ + "cartao_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "lancamentos_conta_id_contas_id_fk": { + "name": "lancamentos_conta_id_contas_id_fk", + "tableFrom": "lancamentos", + "tableTo": "contas", + "columnsFrom": [ + "conta_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "lancamentos_categoria_id_categorias_id_fk": { + "name": "lancamentos_categoria_id_categorias_id_fk", + "tableFrom": "lancamentos", + "tableTo": "categorias", + "columnsFrom": [ + "categoria_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "lancamentos_pagador_id_pagadores_id_fk": { + "name": "lancamentos_pagador_id_pagadores_id_fk", + "tableFrom": "lancamentos", + "tableTo": "pagadores", + "columnsFrom": [ + "pagador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orcamentos": { + "name": "orcamentos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "valor": { + "name": "valor", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "periodo": { + "name": "periodo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "categoria_id": { + "name": "categoria_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orcamentos_user_id_period_idx": { + "name": "orcamentos_user_id_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orcamentos_user_id_user_id_fk": { + "name": "orcamentos_user_id_user_id_fk", + "tableFrom": "orcamentos", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orcamentos_categoria_id_categorias_id_fk": { + "name": "orcamentos_categoria_id_categorias_id_fk", + "tableFrom": "orcamentos", + "tableTo": "categorias", + "columnsFrom": [ + "categoria_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pagadores": { + "name": "pagadores", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_auto_send": { + "name": "is_auto_send", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_code": { + "name": "share_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "substr(encode(gen_random_bytes(24), 'base64'), 1, 24)" + }, + "last_mail": { + "name": "last_mail", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pagadores_share_code_key": { + "name": "pagadores_share_code_key", + "columns": [ + { + "expression": "share_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pagadores_user_id_status_idx": { + "name": "pagadores_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pagadores_user_id_role_idx": { + "name": "pagadores_user_id_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pagadores_user_id_user_id_fk": { + "name": "pagadores_user_id_user_id_fk", + "tableFrom": "pagadores", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_userId_user_id_fk": { + "name": "passkey_userId_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pre_lancamentos": { + "name": "pre_lancamentos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app_name": { + "name": "source_app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_title": { + "name": "original_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_timestamp": { + "name": "notification_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "parsed_name": { + "name": "parsed_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_amount": { + "name": "parsed_amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lancamento_id": { + "name": "lancamento_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "discarded_at": { + "name": "discarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pre_lancamentos_user_id_status_idx": { + "name": "pre_lancamentos_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pre_lancamentos_user_id_created_at_idx": { + "name": "pre_lancamentos_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pre_lancamentos_user_id_user_id_fk": { + "name": "pre_lancamentos_user_id_user_id_fk", + "tableFrom": "pre_lancamentos", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pre_lancamentos_lancamento_id_lancamentos_id_fk": { + "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk", + "tableFrom": "pre_lancamentos", + "tableTo": "lancamentos", + "columnsFrom": [ + "lancamento_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferencias_usuario": { + "name": "preferencias_usuario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extrato_note_as_column": { + "name": "extrato_note_as_column", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "system_font": { + "name": "system_font", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ai-sans'" + }, + "money_font": { + "name": "money_font", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ai-sans'" + }, + "lancamentos_column_order": { + "name": "lancamentos_column_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dashboard_widgets": { + "name": "dashboard_widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "preferencias_usuario_user_id_user_id_fk": { + "name": "preferencias_usuario_user_id_user_id_fk", + "tableFrom": "preferencias_usuario", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preferencias_usuario_user_id_unique": { + "name": "preferencias_usuario_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tokens_api": { + "name": "tokens_api", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_ip": { + "name": "last_used_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tokens_api_user_id_idx": { + "name": "tokens_api_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tokens_api_token_hash_idx": { + "name": "tokens_api_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tokens_api_user_id_user_id_fk": { + "name": "tokens_api_user_id_user_id_fk", + "tableFrom": "tokens_api", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 23712e6..49c3512 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,132 +1,139 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1762993507299, - "tag": "0000_flashy_manta", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1765199006435, - "tag": "0001_young_mister_fear", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1765200545692, - "tag": "0002_slimy_flatman", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1767102605526, - "tag": "0003_green_korg", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1767104066872, - "tag": "0004_acoustic_mach_iv", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1767106121811, - "tag": "0005_adorable_bruce_banner", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1767107487318, - "tag": "0006_youthful_mister_fear", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1767118780033, - "tag": "0007_sturdy_kate_bishop", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1767125796314, - "tag": "0008_fat_stick", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1768925100873, - "tag": "0009_add_dashboard_widgets", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1769369834242, - "tag": "0010_lame_psynapse", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1769447087678, - "tag": "0011_remove_unused_inbox_columns", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1769533200000, - "tag": "0012_rename_tables_to_portuguese", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1769523352777, - "tag": "0013_fancy_rick_jones", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1769619226903, - "tag": "0014_yielding_jack_flag", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1770332054481, - "tag": "0015_concerned_kat_farrell", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1771166328908, - "tag": "0016_complete_randall", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1772400510326, - "tag": "0017_previous_warstar", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1762993507299, + "tag": "0000_flashy_manta", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1765199006435, + "tag": "0001_young_mister_fear", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1765200545692, + "tag": "0002_slimy_flatman", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1767102605526, + "tag": "0003_green_korg", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1767104066872, + "tag": "0004_acoustic_mach_iv", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1767106121811, + "tag": "0005_adorable_bruce_banner", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1767107487318, + "tag": "0006_youthful_mister_fear", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1767118780033, + "tag": "0007_sturdy_kate_bishop", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1767125796314, + "tag": "0008_fat_stick", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1768925100873, + "tag": "0009_add_dashboard_widgets", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1769369834242, + "tag": "0010_lame_psynapse", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1769447087678, + "tag": "0011_remove_unused_inbox_columns", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1769533200000, + "tag": "0012_rename_tables_to_portuguese", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1769523352777, + "tag": "0013_fancy_rick_jones", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1769619226903, + "tag": "0014_yielding_jack_flag", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1770332054481, + "tag": "0015_concerned_kat_farrell", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1771166328908, + "tag": "0016_complete_randall", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1772400510326, + "tag": "0017_previous_warstar", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1773020417482, + "tag": "0018_rainy_epoch", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/lib/dashboard/accounts.ts b/lib/dashboard/accounts.ts index 492d6b4..c71515b 100644 --- a/lib/dashboard/accounts.ts +++ b/lib/dashboard/accounts.ts @@ -1,9 +1,9 @@ import { and, eq, sql } from "drizzle-orm"; import { contas, lancamentos, pagadores } from "@/db/schema"; import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; type RawDashboardAccount = { id: string; diff --git a/lib/dashboard/bills-helpers.ts b/lib/dashboard/bills-helpers.ts new file mode 100644 index 0000000..87281cd --- /dev/null +++ b/lib/dashboard/bills-helpers.ts @@ -0,0 +1,53 @@ +import type { DashboardBill } from "@/lib/dashboard/bills"; +import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller"; +import { getBusinessDateString, isDateOnlyPast } from "@/lib/utils/date"; +import { + buildFinancialStatusLabel, + formatFinancialDateLabel, +} from "@/lib/utils/financial-dates"; + +export type BillDialogState = PaymentDialogState; +export type BillStatusDateItem = Pick< + DashboardBill, + "dueDate" | "boletoPaymentDate" | "isSettled" +>; + +export const formatBillDateLabel = (value: string | null, prefix?: string) => { + return formatFinancialDateLabel(value, prefix); +}; + +export const buildBillStatusLabel = (bill: BillStatusDateItem) => { + return buildFinancialStatusLabel({ + isSettled: bill.isSettled, + dueDate: bill.dueDate, + paidAt: bill.boletoPaymentDate, + }); +}; + +export const getCurrentBillDateString = () => getBusinessDateString(); + +export const isBillOverdue = (bill: DashboardBill) => { + if (bill.isSettled || !bill.dueDate) { + return false; + } + + return isDateOnlyPast(bill.dueDate); +}; + +export const getBillStatusBadgeVariant = ( + statusLabel: string, +): "success" | "info" => { + if (statusLabel.toLowerCase() === "pendente") { + return "info"; + } + return "success"; +}; + +export const markBillAsSettled = ( + bill: DashboardBill, + boletoPaymentDate: string, +): DashboardBill => ({ + ...bill, + isSettled: true, + boletoPaymentDate, +}); diff --git a/lib/dashboard/boletos.ts b/lib/dashboard/bills.ts similarity index 65% rename from lib/dashboard/boletos.ts rename to lib/dashboard/bills.ts index 1d28c97..c6e1d42 100644 --- a/lib/dashboard/boletos.ts +++ b/lib/dashboard/bills.ts @@ -2,13 +2,14 @@ import { and, asc, eq } from "drizzle-orm"; import { lancamentos } from "@/db/schema"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { toDateOnlyString } from "@/lib/utils/date"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; const PAYMENT_METHOD_BOLETO = "Boleto"; -type RawDashboardBoleto = { +type RawDashboardBill = { id: string; name: string; amount: string | number | null; @@ -17,7 +18,7 @@ type RawDashboardBoleto = { isSettled: boolean | null; }; -export type DashboardBoleto = { +export type DashboardBill = { id: string; name: string; amount: number; @@ -26,35 +27,19 @@ export type DashboardBoleto = { isSettled: boolean; }; -export type DashboardBoletosSnapshot = { - boletos: DashboardBoleto[]; +export type DashboardBillsSnapshot = { + bills: DashboardBill[]; totalPendingAmount: number; pendingCount: number; }; -const toISODate = (value: Date | string | null) => { - if (!value) { - return null; - } - - if (value instanceof Date) { - return value.toISOString().slice(0, 10); - } - - if (typeof value === "string") { - return value; - } - - return null; -}; - -export async function fetchDashboardBoletos( +export async function fetchDashboardBills( userId: string, period: string, -): Promise { +): Promise { const adminPagadorId = await getAdminPagadorId(userId); if (!adminPagadorId) { - return { boletos: [], totalPendingAmount: 0, pendingCount: 0 }; + return { bills: [], totalPendingAmount: 0, pendingCount: 0 }; } const rows = await db @@ -81,14 +66,14 @@ export async function fetchDashboardBoletos( asc(lancamentos.name), ); - const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => { + const bills = rows.map((row: RawDashboardBill): DashboardBill => { const amount = Math.abs(toNumber(row.amount)); return { id: row.id, name: row.name, amount, - dueDate: toISODate(row.dueDate), - boletoPaymentDate: toISODate(row.boletoPaymentDate), + dueDate: toDateOnlyString(row.dueDate), + boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate), isSettled: Boolean(row.isSettled), }; }); @@ -96,15 +81,15 @@ export async function fetchDashboardBoletos( let totalPendingAmount = 0; let pendingCount = 0; - for (const boleto of boletos) { - if (!boleto.isSettled) { - totalPendingAmount += boleto.amount; + for (const bill of bills) { + if (!bill.isSettled) { + totalPendingAmount += bill.amount; pendingCount += 1; } } return { - boletos, + bills, totalPendingAmount, pendingCount, }; diff --git a/lib/dashboard/categories/category-breakdown.ts b/lib/dashboard/categories/category-breakdown.ts new file mode 100644 index 0000000..92c221d --- /dev/null +++ b/lib/dashboard/categories/category-breakdown.ts @@ -0,0 +1,121 @@ +import { calculatePercentageChange } from "@/lib/utils/math"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; + +export type DashboardCategoryBreakdownItem = { + categoryId: string; + categoryName: string; + categoryIcon: string | null; + currentAmount: number; + previousAmount: number; + percentageChange: number | null; + percentageOfTotal: number; + budgetAmount: number | null; + budgetUsedPercentage: number | null; +}; + +export type DashboardCategoryBreakdownData = { + categories: DashboardCategoryBreakdownItem[]; + currentTotal: number; + previousTotal: number; +}; + +type CategoryBreakdownRow = { + categoryId: string; + categoryName: string; + categoryIcon: string | null; + period: string | null; + total: unknown; +}; + +type CategoryBudgetRow = { + categoriaId: string | null; + amount: unknown; +}; + +export function buildCategoryBreakdownData({ + rows, + budgetRows, + period, +}: { + rows: CategoryBreakdownRow[]; + budgetRows: CategoryBudgetRow[]; + period: string; +}): DashboardCategoryBreakdownData { + const budgetMap = new Map(); + for (const row of budgetRows) { + if (row.categoriaId) { + budgetMap.set(row.categoriaId, toNumber(row.amount)); + } + } + + const categoryMap = new Map< + string, + { + name: string; + icon: string | null; + current: number; + previous: number; + } + >(); + + for (const row of rows) { + const entry = categoryMap.get(row.categoryId) ?? { + name: row.categoryName, + icon: row.categoryIcon, + current: 0, + previous: 0, + }; + + const amount = Math.abs(toNumber(row.total)); + if (row.period === period) { + entry.current = amount; + } else { + entry.previous = amount; + } + + categoryMap.set(row.categoryId, entry); + } + + let currentTotal = 0; + let previousTotal = 0; + for (const entry of categoryMap.values()) { + currentTotal += entry.current; + previousTotal += entry.previous; + } + + const categories: DashboardCategoryBreakdownItem[] = []; + for (const [categoryId, entry] of categoryMap) { + const percentageChange = calculatePercentageChange( + entry.current, + entry.previous, + ); + const percentageOfTotal = + currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0; + + const budgetAmount = budgetMap.get(categoryId) ?? null; + const budgetUsedPercentage = + budgetAmount && budgetAmount > 0 + ? (entry.current / budgetAmount) * 100 + : null; + + categories.push({ + categoryId, + categoryName: entry.name, + categoryIcon: entry.icon, + currentAmount: entry.current, + previousAmount: entry.previous, + percentageChange, + percentageOfTotal, + budgetAmount, + budgetUsedPercentage, + }); + } + + categories.sort((a, b) => b.currentAmount - a.currentAmount); + + return { + categories, + currentTotal, + previousTotal, + }; +} diff --git a/lib/dashboard/categories/category-details.ts b/lib/dashboard/categories/category-details.ts index 787a23a..f45139b 100644 --- a/lib/dashboard/categories/category-details.ts +++ b/lib/dashboard/categories/category-details.ts @@ -5,10 +5,10 @@ import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { mapLancamentosData } from "@/lib/lancamentos/page-helpers"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; import { getPreviousPeriod } from "@/lib/utils/period"; type MappedLancamentos = ReturnType; diff --git a/lib/dashboard/categories/category-history.ts b/lib/dashboard/categories/category-history.ts index 8438121..997e110 100644 --- a/lib/dashboard/categories/category-history.ts +++ b/lib/dashboard/categories/category-history.ts @@ -1,12 +1,15 @@ -import { addMonths, format } from "date-fns"; -import { ptBR } from "date-fns/locale"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { categorias, lancamentos, pagadores } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { CATEGORY_COLORS } from "@/lib/utils/category-colors"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; +import { + addMonthsToPeriod, + buildPeriodWindow, + formatPeriodMonthShort, +} from "@/lib/utils/period"; export type CategoryOption = { id: string; @@ -34,6 +37,19 @@ export type CategoryHistoryData = { }; const CHART_COLORS = CATEGORY_COLORS; +type MonthlyCategoryRow = { + categoryId: string; + categoryName: string; + categoryIcon: string | null; + period: string; + totalAmount: unknown; +}; + +type UniqueCategory = { + id: string; + name: string; + icon: string | null; +}; export async function fetchAllCategories( userId: string, @@ -61,26 +77,16 @@ export async function fetchCategoryHistory( currentPeriod: string, ): Promise { // Generate last 8 months, current month, and next month (10 total) - const periods: string[] = []; - const monthLabels: string[] = []; - - const [year, month] = currentPeriod.split("-").map(Number); - const currentDate = new Date(year, month - 1, 1); - - // Generate months from -8 to +1 (relative to current) - for (let i = 8; i >= -1; i--) { - const date = addMonths(currentDate, -i); - const period = format(date, "yyyy-MM"); - const label = format(date, "MMM", { locale: ptBR }).toUpperCase(); - periods.push(period); - monthLabels.push(label); - } + const periods = buildPeriodWindow(addMonthsToPeriod(currentPeriod, 1), 10); + const monthLabels = periods.map((period) => + formatPeriodMonthShort(period).toUpperCase(), + ); // Fetch all categories for the selector const allCategories = await fetchAllCategories(userId); // Fetch monthly data for ALL categories with transactions - const monthlyDataQuery = await db + const monthlyDataQuery = (await db .select({ categoryId: categorias.id, categoryName: categorias.name, @@ -112,7 +118,7 @@ export async function fetchCategoryHistory( categorias.name, categorias.icon, lancamentos.period, - ); + )) as MonthlyCategoryRow[]; if (monthlyDataQuery.length === 0) { return { @@ -124,8 +130,8 @@ export async function fetchCategoryHistory( } // Get unique categories from query results - const uniqueCategories = Array.from( - new Map( + const uniqueCategories: UniqueCategory[] = Array.from( + new Map( monthlyDataQuery.map((row) => [ row.categoryId, { @@ -178,15 +184,20 @@ export async function fetchCategoryHistory( }); // Convert to chart data format - const chartData = monthLabels.map((month) => { - const dataPoint: Record = { month }; + const chartData: CategoryHistoryData["chartData"] = monthLabels.map( + (month) => { + const dataPoint: { + month: string; + [categoryName: string]: number | string; + } = { month }; - categoriesMap.forEach((category) => { - dataPoint[category.name] = category.data[month]; - }); + categoriesMap.forEach((category) => { + dataPoint[category.name] = category.data[month]; + }); - return dataPoint; - }); + return dataPoint; + }, + ); return { months: monthLabels, diff --git a/lib/dashboard/categories/expenses-by-category.ts b/lib/dashboard/categories/expenses-by-category.ts index 7aaf5f3..72fd704 100644 --- a/lib/dashboard/categories/expenses-by-category.ts +++ b/lib/dashboard/categories/expenses-by-category.ts @@ -1,29 +1,20 @@ -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { categorias, lancamentos, orcamentos } from "@/db/schema"; -import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; +import { + buildCategoryBreakdownData, + type DashboardCategoryBreakdownData, + type DashboardCategoryBreakdownItem, +} from "@/lib/dashboard/categories/category-breakdown"; +import { + buildDashboardAdminFilters, + excludeAutoInvoiceEntries, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; -import { calculatePercentageChange } from "@/lib/utils/math"; import { getPreviousPeriod } from "@/lib/utils/period"; -export type CategoryExpenseItem = { - categoryId: string; - categoryName: string; - categoryIcon: string | null; - currentAmount: number; - previousAmount: number; - percentageChange: number | null; - percentageOfTotal: number; - budgetAmount: number | null; - budgetUsedPercentage: number | null; -}; - -export type ExpensesByCategoryData = { - categories: CategoryExpenseItem[]; - currentTotal: number; - previousTotal: number; -}; +export type CategoryExpenseItem = DashboardCategoryBreakdownItem; +export type ExpensesByCategoryData = DashboardCategoryBreakdownData; export async function fetchExpensesByCategory( userId: string, @@ -50,15 +41,11 @@ export async function fetchExpensesByCategory( .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.pagadorId, adminPagadorId), + ...buildDashboardAdminFilters({ userId, adminPagadorId }), inArray(lancamentos.period, [period, previousPeriod]), eq(lancamentos.transactionType, "Despesa"), eq(categorias.type, "despesa"), - or( - isNull(lancamentos.note), - sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), + excludeAutoInvoiceEntries(), ), ) .groupBy( @@ -76,85 +63,9 @@ export async function fetchExpensesByCategory( .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))), ]); - // Build budget lookup - const budgetMap = new Map(); - for (const row of budgetRows) { - if (row.categoriaId) { - budgetMap.set(row.categoriaId, toNumber(row.amount)); - } - } - - // Build category data from grouped results - const categoryMap = new Map< - string, - { - name: string; - icon: string | null; - current: number; - previous: number; - } - >(); - - for (const row of rows) { - const entry = categoryMap.get(row.categoryId) ?? { - name: row.categoryName, - icon: row.categoryIcon, - current: 0, - previous: 0, - }; - - const amount = Math.abs(toNumber(row.total)); - if (row.period === period) { - entry.current = amount; - } else { - entry.previous = amount; - } - categoryMap.set(row.categoryId, entry); - } - - // Calculate totals - let currentTotal = 0; - let previousTotal = 0; - for (const entry of categoryMap.values()) { - currentTotal += entry.current; - previousTotal += entry.previous; - } - - // Build result - const categories: CategoryExpenseItem[] = []; - for (const [categoryId, entry] of categoryMap) { - const percentageChange = calculatePercentageChange( - entry.current, - entry.previous, - ); - const percentageOfTotal = - currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0; - - const budgetAmount = budgetMap.get(categoryId) ?? null; - const budgetUsedPercentage = - budgetAmount && budgetAmount > 0 - ? (entry.current / budgetAmount) * 100 - : null; - - categories.push({ - categoryId, - categoryName: entry.name, - categoryIcon: entry.icon, - currentAmount: entry.current, - previousAmount: entry.previous, - percentageChange, - percentageOfTotal, - budgetAmount, - budgetUsedPercentage, - }); - } - - // Ordena por valor atual (maior para menor) - categories.sort((a, b) => b.currentAmount - a.currentAmount); - - return { - categories, - currentTotal, - previousTotal, - }; + return buildCategoryBreakdownData({ + rows, + budgetRows, + period, + }); } diff --git a/lib/dashboard/categories/income-by-category.ts b/lib/dashboard/categories/income-by-category.ts index dd38246..2c48481 100644 --- a/lib/dashboard/categories/income-by-category.ts +++ b/lib/dashboard/categories/income-by-category.ts @@ -1,32 +1,21 @@ -import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { categorias, contas, lancamentos, orcamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; + buildCategoryBreakdownData, + type DashboardCategoryBreakdownData, + type DashboardCategoryBreakdownItem, +} from "@/lib/dashboard/categories/category-breakdown"; +import { + buildDashboardAdminFilters, + excludeAutoInvoiceEntries, + excludeInitialBalanceWhenConfigured, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; -import { calculatePercentageChange } from "@/lib/utils/math"; -import { safeToNumber } from "@/lib/utils/number"; import { getPreviousPeriod } from "@/lib/utils/period"; -export type CategoryIncomeItem = { - categoryId: string; - categoryName: string; - categoryIcon: string | null; - currentAmount: number; - previousAmount: number; - percentageChange: number | null; - percentageOfTotal: number; - budgetAmount: number | null; - budgetUsedPercentage: number | null; -}; - -export type IncomeByCategoryData = { - categories: CategoryIncomeItem[]; - currentTotal: number; - previousTotal: number; -}; +export type CategoryIncomeItem = DashboardCategoryBreakdownItem; +export type IncomeByCategoryData = DashboardCategoryBreakdownData; export async function fetchIncomeByCategory( userId: string, @@ -54,21 +43,12 @@ export async function fetchIncomeByCategory( .leftJoin(contas, eq(lancamentos.contaId, contas.id)) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.pagadorId, adminPagadorId), + ...buildDashboardAdminFilters({ userId, adminPagadorId }), inArray(lancamentos.period, [period, previousPeriod]), eq(lancamentos.transactionType, "Receita"), eq(categorias.type, "receita"), - or( - isNull(lancamentos.note), - sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - // Excluir saldos iniciais se a conta tiver o flag ativo - or( - ne(lancamentos.note, INITIAL_BALANCE_NOTE), - isNull(contas.excludeInitialBalanceFromIncome), - eq(contas.excludeInitialBalanceFromIncome, false), - ), + excludeAutoInvoiceEntries(), + excludeInitialBalanceWhenConfigured(), ), ) .groupBy( @@ -86,85 +66,9 @@ export async function fetchIncomeByCategory( .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))), ]); - // Build budget lookup - const budgetMap = new Map(); - for (const row of budgetRows) { - if (row.categoriaId) { - budgetMap.set(row.categoriaId, safeToNumber(row.amount)); - } - } - - // Build category data from grouped results - const categoryMap = new Map< - string, - { - name: string; - icon: string | null; - current: number; - previous: number; - } - >(); - - for (const row of rows) { - const entry = categoryMap.get(row.categoryId) ?? { - name: row.categoryName, - icon: row.categoryIcon, - current: 0, - previous: 0, - }; - - const amount = Math.abs(safeToNumber(row.total)); - if (row.period === period) { - entry.current = amount; - } else { - entry.previous = amount; - } - categoryMap.set(row.categoryId, entry); - } - - // Calculate totals - let currentTotal = 0; - let previousTotal = 0; - for (const entry of categoryMap.values()) { - currentTotal += entry.current; - previousTotal += entry.previous; - } - - // Build result - const categories: CategoryIncomeItem[] = []; - for (const [categoryId, entry] of categoryMap) { - const percentageChange = calculatePercentageChange( - entry.current, - entry.previous, - ); - const percentageOfTotal = - currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0; - - const budgetAmount = budgetMap.get(categoryId) ?? null; - const budgetUsedPercentage = - budgetAmount && budgetAmount > 0 - ? (entry.current / budgetAmount) * 100 - : null; - - categories.push({ - categoryId, - categoryName: entry.name, - categoryIcon: entry.icon, - currentAmount: entry.current, - previousAmount: entry.previous, - percentageChange, - percentageOfTotal, - budgetAmount, - budgetUsedPercentage, - }); - } - - // Ordena por valor atual (maior para menor) - categories.sort((a, b) => b.currentAmount - a.currentAmount); - - return { - categories, - currentTotal, - previousTotal, - }; + return buildCategoryBreakdownData({ + rows, + budgetRows, + period, + }); } diff --git a/lib/dashboard/common.ts b/lib/dashboard/common.ts deleted file mode 100644 index 3b08baf..0000000 --- a/lib/dashboard/common.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { calculatePercentageChange } from "@/lib/utils/math"; -import { safeToNumber } from "@/lib/utils/number"; - -export { safeToNumber, calculatePercentageChange }; - -/** - * Alias for backward compatibility - dashboard uses "toNumber" naming - */ -export const toNumber = safeToNumber; diff --git a/lib/dashboard/metrics.ts b/lib/dashboard/dashboard-metrics.ts similarity index 86% rename from lib/dashboard/metrics.ts rename to lib/dashboard/dashboard-metrics.ts index 05022d2..c6246f7 100644 --- a/lib/dashboard/metrics.ts +++ b/lib/dashboard/dashboard-metrics.ts @@ -1,21 +1,10 @@ -import { - and, - asc, - eq, - gte, - ilike, - isNull, - lte, - ne, - not, - or, - sum, -} from "drizzle-orm"; +import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm"; import { contas, lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; + buildDashboardAdminFilters, + excludeAutoInvoiceEntries, + excludeInitialBalanceWhenConfigured, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; import { safeToNumber } from "@/lib/utils/number"; @@ -107,21 +96,12 @@ export async function fetchDashboardCardMetrics( .leftJoin(contas, eq(lancamentos.contaId, contas.id)) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.pagadorId, adminPagadorId), + ...buildDashboardAdminFilters({ userId, adminPagadorId }), gte(lancamentos.period, startPeriod), lte(lancamentos.period, period), ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), - ), - // Excluir saldos iniciais se a conta tiver o flag ativo - or( - ne(lancamentos.note, INITIAL_BALANCE_NOTE), - isNull(contas.excludeInitialBalanceFromIncome), - eq(contas.excludeInitialBalanceFromIncome, false), - ), + excludeAutoInvoiceEntries(), + excludeInitialBalanceWhenConfigured(), ), ) .groupBy(lancamentos.period, lancamentos.transactionType) diff --git a/lib/dashboard/expenses/installment-analysis.ts b/lib/dashboard/expenses/installment-analysis.ts index c65b073..c458464 100644 --- a/lib/dashboard/expenses/installment-analysis.ts +++ b/lib/dashboard/expenses/installment-analysis.ts @@ -4,23 +4,28 @@ import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { + buildDateOnlyStringFromPeriodDay, + parseLocalDateString, +} from "@/lib/utils/date"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; // Calcula a data de vencimento baseada no período e dia de vencimento do cartão function calculateDueDate(period: string, dueDay: string | null): Date | null { if (!dueDay) return null; try { - const [year, month] = period.split("-"); - if (!year || !month) return null; + const dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay); + if (!dueDateString) return null; - const day = parseInt(dueDay, 10); - if (Number.isNaN(day)) return null; + const dueDate = parseLocalDateString(dueDateString); + if (Number.isNaN(dueDate.getTime())) return null; - // Criar data ao meio-dia para evitar problemas de timezone - return new Date(parseInt(year, 10), parseInt(month, 10) - 1, day, 12, 0, 0); + // Meio-dia evita drift visual em serialização/locales diferentes. + dueDate.setHours(12, 0, 0, 0); + return dueDate; } catch { return null; } diff --git a/lib/dashboard/expenses/installment-expenses.ts b/lib/dashboard/expenses/installment-expenses.ts index 939e38d..5b14b6d 100644 --- a/lib/dashboard/expenses/installment-expenses.ts +++ b/lib/dashboard/expenses/installment-expenses.ts @@ -4,9 +4,9 @@ import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type InstallmentExpense = { id: string; diff --git a/lib/dashboard/expenses/recurring-expenses.ts b/lib/dashboard/expenses/recurring-expenses.ts index 94e5cf7..bd11cd8 100644 --- a/lib/dashboard/expenses/recurring-expenses.ts +++ b/lib/dashboard/expenses/recurring-expenses.ts @@ -4,9 +4,9 @@ import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type RecurringExpense = { id: string; diff --git a/lib/dashboard/expenses/top-expenses.ts b/lib/dashboard/expenses/top-expenses.ts index 07c8136..71bd899 100644 --- a/lib/dashboard/expenses/top-expenses.ts +++ b/lib/dashboard/expenses/top-expenses.ts @@ -1,12 +1,12 @@ -import { and, asc, eq, isNull, or, sql } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { cartoes, contas, lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; + buildDashboardAdminPeriodFilters, + excludeAutoGeneratedEntryNotes, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type TopExpense = { id: string; @@ -32,19 +32,13 @@ export async function fetchTopExpenses( } const conditions = [ - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), + ...buildDashboardAdminPeriodFilters({ + userId, + period, + adminPagadorId, + }), eq(lancamentos.transactionType, "Despesa"), - eq(lancamentos.pagadorId, adminPagadorId), - or( - isNull(lancamentos.note), - and( - sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), + excludeAutoGeneratedEntryNotes(), ]; // Se cardOnly for true, filtra apenas pagamentos com cartão @@ -72,7 +66,7 @@ export async function fetchTopExpenses( .limit(10); const expenses = results.map( - (row): TopExpense => ({ + (row: (typeof results)[number]): TopExpense => ({ id: row.id, name: row.name, amount: Math.abs(toNumber(row.amount)), diff --git a/lib/dashboard/fetch-dashboard-data.ts b/lib/dashboard/fetch-dashboard-data.ts index 989a535..fdb71e5 100644 --- a/lib/dashboard/fetch-dashboard-data.ts +++ b/lib/dashboard/fetch-dashboard-data.ts @@ -1,15 +1,15 @@ import { unstable_cache } from "next/cache"; import { fetchDashboardAccounts } from "./accounts"; -import { fetchDashboardBoletos } from "./boletos"; +import { fetchDashboardBills } from "./bills"; import { fetchExpensesByCategory } from "./categories/expenses-by-category"; import { fetchIncomeByCategory } from "./categories/income-by-category"; +import { fetchDashboardCardMetrics } from "./dashboard-metrics"; import { fetchInstallmentExpenses } from "./expenses/installment-expenses"; import { fetchRecurringExpenses } from "./expenses/recurring-expenses"; import { fetchTopExpenses } from "./expenses/top-expenses"; import { fetchGoalsProgressData } from "./goals-progress"; import { fetchIncomeExpenseBalance } from "./income-expense-balance"; import { fetchDashboardInvoices } from "./invoices"; -import { fetchDashboardCardMetrics } from "./metrics"; import { fetchDashboardNotes } from "./notes"; import { fetchDashboardPagadores } from "./pagadores"; import { fetchPaymentConditions } from "./payments/payment-conditions"; @@ -23,7 +23,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) { metrics, accountsSnapshot, invoicesSnapshot, - boletosSnapshot, + billsSnapshot, goalsProgressData, paymentStatusData, incomeExpenseBalanceData, @@ -43,7 +43,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) { fetchDashboardCardMetrics(userId, period), fetchDashboardAccounts(userId), fetchDashboardInvoices(userId, period), - fetchDashboardBoletos(userId, period), + fetchDashboardBills(userId, period), fetchGoalsProgressData(userId, period), fetchPaymentStatus(userId, period), fetchIncomeExpenseBalance(userId, period), @@ -65,7 +65,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) { metrics, accountsSnapshot, invoicesSnapshot, - boletosSnapshot, + billsSnapshot, goalsProgressData, paymentStatusData, incomeExpenseBalanceData, @@ -95,7 +95,7 @@ export function fetchDashboardData(userId: string, period: string) { [`dashboard-${userId}-${period}`], { tags: ["dashboard", `dashboard-${userId}`], - revalidate: 120, + revalidate: 60, }, )(); } diff --git a/lib/dashboard/goals-progress-helpers.ts b/lib/dashboard/goals-progress-helpers.ts new file mode 100644 index 0000000..75b240f --- /dev/null +++ b/lib/dashboard/goals-progress-helpers.ts @@ -0,0 +1,45 @@ +import type { Budget, BudgetCategory } from "@/components/orcamentos/types"; +import type { + GoalProgressCategory, + GoalProgressItem, + GoalProgressStatus, +} from "@/lib/dashboard/goals-progress"; +import { formatPercentage } from "@/lib/utils/percentage"; + +export const clampGoalProgress = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +export const formatGoalProgressPercentage = (value: number, withSign = false) => + formatPercentage(value, { + maximumFractionDigits: 1, + signDisplay: withSign ? "always" : "auto", + }); + +export const getGoalProgressStatusColorClass = (status: GoalProgressStatus) => + status === "exceeded" ? "text-destructive" : ""; + +export const mapGoalProgressCategoriesToBudgetCategories = ( + categories: GoalProgressCategory[], +): BudgetCategory[] => + categories.map((category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + })); + +export const mapGoalProgressItemToBudget = ( + item: GoalProgressItem, +): Budget => ({ + id: item.id, + amount: item.budgetAmount, + spent: item.spentAmount, + period: item.period, + createdAt: item.createdAt, + category: item.categoryId + ? { + id: item.categoryId, + name: item.categoryName, + icon: item.categoryIcon, + } + : null, +}); diff --git a/lib/dashboard/goals-progress.ts b/lib/dashboard/goals-progress.ts index 3142a57..a1d5a86 100644 --- a/lib/dashboard/goals-progress.ts +++ b/lib/dashboard/goals-progress.ts @@ -1,8 +1,8 @@ import { and, eq, ne, sql } from "drizzle-orm"; import { categorias, lancamentos, orcamentos } from "@/db/schema"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; const BUDGET_CRITICAL_THRESHOLD = 80; diff --git a/lib/dashboard/income-expense-balance.ts b/lib/dashboard/income-expense-balance.ts index 9cabbab..9c9cb66 100644 --- a/lib/dashboard/income-expense-balance.ts +++ b/lib/dashboard/income-expense-balance.ts @@ -1,12 +1,18 @@ -import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { contas, lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; + buildDashboardAdminFilters, + excludeAutoInvoiceEntries, + excludeInitialBalanceWhenConfigured, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; +import { + buildPeriodWindow, + formatPeriodMonthShort, + getCurrentPeriod, +} from "@/lib/utils/period"; export type MonthData = { month: string; @@ -20,47 +26,12 @@ export type IncomeExpenseBalanceData = { months: MonthData[]; }; -const MONTH_LABELS: Record = { - "01": "jan", - "02": "fev", - "03": "mar", - "04": "abr", - "05": "mai", - "06": "jun", - "07": "jul", - "08": "ago", - "09": "set", - "10": "out", - "11": "nov", - "12": "dez", -}; - const generateLast6Months = (currentPeriod: string): string[] => { - const [yearStr, monthStr] = currentPeriod.split("-"); - let year = Number.parseInt(yearStr ?? "", 10); - let month = Number.parseInt(monthStr ?? "", 10); - - if (Number.isNaN(year) || Number.isNaN(month)) { - const now = new Date(); - year = now.getFullYear(); - month = now.getMonth() + 1; + try { + return buildPeriodWindow(currentPeriod, 6); + } catch { + return buildPeriodWindow(getCurrentPeriod(), 6); } - - const periods: string[] = []; - - for (let i = 5; i >= 0; i--) { - let targetMonth = month - i; - let targetYear = year; - - while (targetMonth <= 0) { - targetMonth += 12; - targetYear -= 1; - } - - periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`); - } - - return periods; }; export async function fetchIncomeExpenseBalance( @@ -85,17 +56,11 @@ export async function fetchIncomeExpenseBalance( .leftJoin(contas, eq(lancamentos.contaId, contas.id)) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.pagadorId, adminPagadorId), + ...buildDashboardAdminFilters({ userId, adminPagadorId }), inArray(lancamentos.period, periods), inArray(lancamentos.transactionType, ["Receita", "Despesa"]), - sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`, - // Excluir saldos iniciais se a conta tiver o flag ativo - or( - ne(lancamentos.note, INITIAL_BALANCE_NOTE), - isNull(contas.excludeInitialBalanceFromIncome), - eq(contas.excludeInitialBalanceFromIncome, false), - ), + excludeAutoInvoiceEntries(), + excludeInitialBalanceWhenConfigured(), ), ) .groupBy(lancamentos.period, lancamentos.transactionType); @@ -117,12 +82,10 @@ export async function fetchIncomeExpenseBalance( // Build result array preserving period order const months = periods.map((period) => { const entry = dataMap.get(period) ?? { income: 0, expense: 0 }; - const [, monthPart] = period.split("-"); - const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart; return { month: period, - monthLabel: monthLabel ?? "", + monthLabel: formatPeriodMonthShort(period).toLowerCase(), income: entry.income, expense: entry.expense, balance: entry.income - entry.expense, diff --git a/lib/dashboard/installment-expenses-helpers.ts b/lib/dashboard/installment-expenses-helpers.ts new file mode 100644 index 0000000..345374a --- /dev/null +++ b/lib/dashboard/installment-expenses-helpers.ts @@ -0,0 +1,116 @@ +import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses"; +import { + calculateLastInstallmentDate, + formatLastInstallmentDate, +} from "@/lib/installments/utils"; + +export type InstallmentExpenseDisplay = { + compactLabel: string | null; + isLast: boolean; + remainingInstallments: number; + remainingAmount: number; + endDate: string | null; + progress: number; +}; + +export const buildInstallmentCompactLabel = ( + currentInstallment: number | null, + installmentCount: number | null, +) => { + if (currentInstallment && installmentCount) { + return `${currentInstallment} de ${installmentCount}`; + } + + return null; +}; + +export const isInstallmentLast = ( + currentInstallment: number | null, + installmentCount: number | null, +) => { + if (!currentInstallment || !installmentCount) { + return false; + } + + return currentInstallment === installmentCount && installmentCount > 1; +}; + +export const calculateInstallmentRemainingCount = ( + currentInstallment: number | null, + installmentCount: number | null, +) => { + if (!currentInstallment || !installmentCount) { + return 0; + } + + return Math.max(0, installmentCount - currentInstallment); +}; + +export const calculateInstallmentRemainingAmount = ( + amount: number, + currentInstallment: number | null, + installmentCount: number | null, +) => + amount * + calculateInstallmentRemainingCount(currentInstallment, installmentCount); + +export const formatInstallmentEndDate = ( + period: string, + currentInstallment: number | null, + installmentCount: number | null, +) => { + if (!currentInstallment || !installmentCount) { + return null; + } + + const lastDate = calculateLastInstallmentDate( + period, + currentInstallment, + installmentCount, + ); + + return formatLastInstallmentDate(lastDate); +}; + +export const buildInstallmentProgress = ( + currentInstallment: number | null, + installmentCount: number | null, +) => { + if (!currentInstallment || !installmentCount || installmentCount <= 0) { + return 0; + } + + return Math.min( + 100, + Math.max(0, (currentInstallment / installmentCount) * 100), + ); +}; + +export const buildInstallmentExpenseDisplay = ( + expense: InstallmentExpense, +): InstallmentExpenseDisplay => { + const { amount, currentInstallment, installmentCount, period } = expense; + + return { + compactLabel: buildInstallmentCompactLabel( + currentInstallment, + installmentCount, + ), + isLast: isInstallmentLast(currentInstallment, installmentCount), + remainingInstallments: calculateInstallmentRemainingCount( + currentInstallment, + installmentCount, + ), + remainingAmount: calculateInstallmentRemainingAmount( + amount, + currentInstallment, + installmentCount, + ), + endDate: formatInstallmentEndDate( + period, + currentInstallment, + installmentCount, + ), + progress: buildInstallmentProgress(currentInstallment, installmentCount), + }; +}; diff --git a/lib/dashboard/invoices-helpers.ts b/lib/dashboard/invoices-helpers.ts new file mode 100644 index 0000000..f3303bd --- /dev/null +++ b/lib/dashboard/invoices-helpers.ts @@ -0,0 +1,104 @@ +import type { DashboardInvoice } from "@/lib/dashboard/invoices"; +import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller"; +import { + INVOICE_PAYMENT_STATUS, + type InvoicePaymentStatus, +} from "@/lib/faturas"; +import { getBusinessDateString } from "@/lib/utils/date"; +import { + buildDueDateInfoFromPeriodDay, + formatFinancialDateLabel, +} from "@/lib/utils/financial-dates"; +import { formatPercentage } from "@/lib/utils/percentage"; +import { formatPeriodForUrl } from "@/lib/utils/period"; + +export type InvoiceDialogState = PaymentDialogState; +export type InvoiceLogoTone = "muted" | "accent"; + +type InvoicePaymentDateInfo = { + label: string; +}; + +type InvoiceDueDateInfo = { + label: string; + date: string | null; +}; + +export const buildInvoiceInitials = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + return "CC"; + } + if (parts.length === 1) { + const firstPart = parts[0]; + return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC"; + } + const firstChar = parts[0]?.[0] ?? ""; + const secondChar = parts[1]?.[0] ?? ""; + return `${firstChar}${secondChar}`.toUpperCase() || "CC"; +}; + +export const parseInvoiceDueDate = ( + period: string, + dueDay: string, +): InvoiceDueDateInfo => { + return buildDueDateInfoFromPeriodDay(period, dueDay); +}; + +export const formatInvoicePaymentDate = ( + value: string | null, +): InvoicePaymentDateInfo | null => { + const label = formatFinancialDateLabel(value, "Pago em"); + if (!label) { + return null; + } + + return { + label, + }; +}; + +export const getCurrentDateString = () => getBusinessDateString(); + +const formatInvoiceSharePercentage = (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return "0%"; + } + const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2; + return formatPercentage(value, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +}; + +export const getInvoiceShareLabel = (amount: number, total: number) => { + if (total <= 0) { + return "0% do total"; + } + const percentage = (amount / total) * 100; + return `${formatInvoiceSharePercentage(percentage)} do total`; +}; + +export const getInvoiceStatusBadgeVariant = ( + statusLabel: string, +): "success" | "info" => { + if (statusLabel.toLowerCase() === "em aberto") { + return "info"; + } + return "success"; +}; + +export const buildInvoiceDetailsHref = (cardId: string, period: string) => + `/cartoes/${cardId}/fatura?periodo=${formatPeriodForUrl(period)}`; + +export const markInvoiceAsPaid = ( + invoice: DashboardInvoice, + paidAt: string, +): DashboardInvoice => ({ + ...invoice, + paymentStatus: INVOICE_PAYMENT_STATUS.PAID, + paidAt, +}); + +export const isInvoicePaid = (status: InvoicePaymentStatus) => + status === INVOICE_PAYMENT_STATUS.PAID; diff --git a/lib/dashboard/invoices.ts b/lib/dashboard/invoices.ts index b19a0e6..a8287e9 100644 --- a/lib/dashboard/invoices.ts +++ b/lib/dashboard/invoices.ts @@ -1,13 +1,14 @@ import { and, eq, ilike, isNotNull, sql } from "drizzle-orm"; import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_VALUES, type InvoicePaymentStatus, } from "@/lib/faturas"; +import { toDateOnlyString } from "@/lib/utils/date"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; type RawDashboardInvoice = { invoiceId: string | null; @@ -24,6 +25,15 @@ type RawDashboardInvoice = { invoiceCreatedAt: Date | null; }; +type RawInvoiceBreakdownRow = { + cardId: string | null; + period: string | null; + pagadorId: string | null; + pagadorName: string | null; + pagadorAvatar: string | null; + amount: number | string | null; +}; + export type InvoicePagadorBreakdown = { pagadorId: string | null; pagadorName: string; @@ -51,22 +61,6 @@ export type DashboardInvoicesSnapshot = { totalPending: number; }; -const toISODate = (value: Date | string | null | undefined) => { - if (!value) { - return null; - } - - if (value instanceof Date) { - return value.toISOString().slice(0, 10); - } - - if (typeof value === "string") { - return value.slice(0, 10); - } - - return null; -}; - const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus => typeof value === "string" && (INVOICE_STATUS_VALUES as string[]).includes(value); @@ -113,7 +107,7 @@ export async function fetchDashboardInvoices( !Number.isNaN(row.purchaseDate.valueOf()) ? row.purchaseDate : row.createdAt; - const isoDate = toISODate(resolvedDate); + const isoDate = toDateOnlyString(resolvedDate); if (!isoDate) { continue; } @@ -123,7 +117,10 @@ export async function fetchDashboardInvoices( } } - const [rows, breakdownRows] = await Promise.all([ + const [rows, breakdownRows]: [ + RawDashboardInvoice[], + RawInvoiceBreakdownRow[], + ] = await Promise.all([ db .select({ invoiceId: faturas.id, @@ -216,54 +213,57 @@ export async function fetchDashboardInvoices( breakdownMap.set(key, current); } - const invoices = rows - .map((row: RawDashboardInvoice | null) => { - if (!row) return null; + const invoices: DashboardInvoice[] = []; - const totalAmount = toNumber(row.totalAmount); - const transactionCount = toNumber(row.transactionCount); - const paymentStatus = isInvoiceStatus(row.paymentStatus) - ? row.paymentStatus - : INVOICE_PAYMENT_STATUS.PENDING; + for (const row of rows) { + if (!row) { + continue; + } - const shouldInclude = - transactionCount > 0 || - Math.abs(totalAmount) > 0 || - row.invoiceId !== null; + const totalAmount = toNumber(row.totalAmount); + const transactionCount = toNumber(row.transactionCount); + const paymentStatus = isInvoiceStatus(row.paymentStatus) + ? row.paymentStatus + : INVOICE_PAYMENT_STATUS.PENDING; - if (!shouldInclude) { - return null; - } + const shouldInclude = + transactionCount > 0 || + Math.abs(totalAmount) > 0 || + row.invoiceId !== null; - const resolvedPeriod = row.period ?? period; - const paymentKey = `${row.cardId}:${resolvedPeriod}`; - const paidAt = - paymentStatus === INVOICE_PAYMENT_STATUS.PAID - ? (paymentMap.get(paymentKey) ?? toISODate(row.invoiceCreatedAt)) - : null; + if (!shouldInclude) { + continue; + } - return { - id: row.invoiceId ?? buildFallbackId(row.cardId, period), - cardId: row.cardId, - cardName: row.cardName, - cardBrand: row.cardBrand, - cardStatus: row.cardStatus, - logo: row.logo, - dueDay: row.dueDay, - period: resolvedPeriod, - paymentStatus, - totalAmount, - paidAt, - pagadorBreakdown: ( - breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? [] - ).sort((a, b) => b.amount - a.amount), - } satisfies DashboardInvoice; - }) - .filter((invoice): invoice is DashboardInvoice => invoice !== null) - .sort((a, b) => { - // Ordena do maior valor para o menor - return Math.abs(b.totalAmount) - Math.abs(a.totalAmount); + const resolvedPeriod = row.period ?? period; + const paymentKey = `${row.cardId}:${resolvedPeriod}`; + const paidAt = + paymentStatus === INVOICE_PAYMENT_STATUS.PAID + ? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt)) + : null; + + invoices.push({ + id: row.invoiceId ?? buildFallbackId(row.cardId, period), + cardId: row.cardId, + cardName: row.cardName, + cardBrand: row.cardBrand, + cardStatus: row.cardStatus, + logo: row.logo, + dueDay: row.dueDay, + period: resolvedPeriod, + paymentStatus, + totalAmount, + paidAt, + pagadorBreakdown: ( + breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? [] + ).sort((a, b) => b.amount - a.amount), }); + } + + invoices.sort((a, b) => { + // Ordena do maior valor para o menor + return Math.abs(b.totalAmount) - Math.abs(a.totalAmount); + }); const totalPending = invoices.reduce((total, invoice) => { if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) { diff --git a/lib/dashboard/lancamento-filters.ts b/lib/dashboard/lancamento-filters.ts new file mode 100644 index 0000000..3ba7440 --- /dev/null +++ b/lib/dashboard/lancamento-filters.ts @@ -0,0 +1,56 @@ +import { and, eq, ilike, isNull, ne, not, or } from "drizzle-orm"; +import { contas, lancamentos } from "@/db/schema"; +import { + ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, + INITIAL_BALANCE_NOTE, +} from "@/lib/contas/constants"; + +type DashboardAdminFiltersParams = { + userId: string; + adminPagadorId: string; +}; + +type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & { + period: string; +}; + +export const buildDashboardAdminFilters = ({ + userId, + adminPagadorId, +}: DashboardAdminFiltersParams) => + [ + eq(lancamentos.userId, userId), + eq(lancamentos.pagadorId, adminPagadorId), + ] as const; + +export const buildDashboardAdminPeriodFilters = ({ + userId, + period, + adminPagadorId, +}: DashboardAdminPeriodFiltersParams) => + [ + ...buildDashboardAdminFilters({ userId, adminPagadorId }), + eq(lancamentos.period, period), + ] as const; + +export const excludeAutoInvoiceEntries = () => + or( + isNull(lancamentos.note), + not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), + ); + +export const excludeAutoGeneratedEntryNotes = () => + or( + isNull(lancamentos.note), + and( + ne(lancamentos.note, INITIAL_BALANCE_NOTE), + not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), + ), + ); + +export const excludeInitialBalanceWhenConfigured = () => + or( + ne(lancamentos.note, INITIAL_BALANCE_NOTE), + isNull(contas.excludeInitialBalanceFromIncome), + eq(contas.excludeInitialBalanceFromIncome, false), + ); diff --git a/lib/dashboard/notes-mappers.ts b/lib/dashboard/notes-mappers.ts new file mode 100644 index 0000000..9dba6fc --- /dev/null +++ b/lib/dashboard/notes-mappers.ts @@ -0,0 +1,15 @@ +import type { Note } from "@/components/anotacoes/types"; +import type { DashboardNote } from "@/lib/dashboard/notes"; + +export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({ + id: note.id, + title: note.title, + description: note.description, + type: note.type, + tasks: note.tasks, + arquivada: note.arquivada, + createdAt: note.createdAt, +}); + +export const mapDashboardNotesToNotes = (notes: DashboardNote[]) => + notes.map(mapDashboardNoteToNote); diff --git a/lib/dashboard/notifications.ts b/lib/dashboard/notifications.ts index b6819c1..8b3ec01 100644 --- a/lib/dashboard/notifications.ts +++ b/lib/dashboard/notifications.ts @@ -11,6 +11,14 @@ import { import { db } from "@/lib/db"; import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { + buildDateOnlyStringFromPeriodDay, + getBusinessDateString, + isDateOnlyPast, + isDateOnlyWithinDays, + toDateOnlyString, +} from "@/lib/utils/date"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type NotificationType = "overdue" | "due_soon"; @@ -46,100 +54,6 @@ export type DashboardNotificationsSnapshot = { const PAYMENT_METHOD_BOLETO = "Boleto"; const BUDGET_CRITICAL_THRESHOLD = 80; -/** - * Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento - * @param period Período no formato YYYY-MM - * @param dueDay Dia do vencimento (1-31) - * @returns Data de vencimento no formato YYYY-MM-DD - */ -function calculateDueDate(period: string, dueDay: string): string { - const [year, month] = period.split("-"); - const yearNumber = Number(year); - const monthNumber = Number(month); - const hasValidMonth = - Number.isInteger(yearNumber) && - Number.isInteger(monthNumber) && - monthNumber >= 1 && - monthNumber <= 12; - - const daysInMonth = hasValidMonth - ? new Date(yearNumber, monthNumber, 0).getDate() - : null; - - const dueDayNumber = Number(dueDay); - const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0; - - const clampedDay = - hasValidMonth && hasValidDueDay && daysInMonth - ? Math.min(dueDayNumber, daysInMonth) - : hasValidDueDay - ? dueDayNumber - : null; - - const day = clampedDay - ? String(clampedDay).padStart(2, "0") - : dueDay.padStart(2, "0"); - - const normalizedMonth = - hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month; - - return `${year}-${normalizedMonth}-${day}`; -} - -/** - * Normaliza uma data para o início do dia em UTC (00:00:00) - */ -function normalizeDate(date: Date): Date { - return new Date( - Date.UTC( - date.getUTCFullYear(), - date.getUTCMonth(), - date.getUTCDate(), - 0, - 0, - 0, - 0, - ), - ); -} - -/** - * Converte string "YYYY-MM-DD" para Date em UTC (evita problemas de timezone) - */ -function parseUTCDate(dateString: string): Date { - const [year, month, day] = dateString.split("-").map(Number); - return new Date(Date.UTC(year, month - 1, day)); -} - -/** - * Verifica se uma data está atrasada (antes do dia atual, não incluindo hoje) - */ -function isOverdue(dueDate: string, today: Date): boolean { - const due = parseUTCDate(dueDate); - const dueNormalized = normalizeDate(due); - return dueNormalized < today; -} - -/** - * Verifica se uma data vence nos próximos X dias (incluindo hoje) - */ -function isDueWithinDays( - dueDate: string, - today: Date, - daysThreshold: number, -): boolean { - const due = parseUTCDate(dueDate); - const dueNormalized = normalizeDate(due); - const limitDate = new Date(today); - limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold); - return dueNormalized >= today && dueNormalized <= limitDate; -} - -function toNum(value: unknown): number { - if (typeof value === "number") return value; - return Number(value) || 0; -} - /** * Busca todas as notificações do dashboard: * - Faturas de cartão atrasadas ou com vencimento próximo @@ -150,7 +64,7 @@ export async function fetchDashboardNotifications( userId: string, currentPeriod: string, ): Promise { - const today = normalizeDate(new Date()); + const today = getBusinessDateString(); const DAYS_THRESHOLD = 5; const adminPagadorId = await getAdminPagadorId(userId); @@ -285,8 +199,12 @@ export async function fetchDashboardNotifications( // Faturas atrasadas (períodos anteriores) for (const invoice of overdueInvoices) { if (!invoice.period || !invoice.dueDay) continue; - const dueDate = calculateDueDate(invoice.period, invoice.dueDay); - const amount = toNum(invoice.totalAmount); + const dueDate = buildDateOnlyStringFromPeriodDay( + invoice.period, + invoice.dueDay, + ); + if (!dueDate) continue; + const amount = toNumber(invoice.totalAmount); const notificationId = invoice.invoiceId ? `invoice-${invoice.invoiceId}` : `invoice-${invoice.cardId}-${invoice.period}`; @@ -307,8 +225,13 @@ export async function fetchDashboardNotifications( // Faturas do período atual for (const invoice of currentInvoices) { if (!invoice.period || !invoice.dueDay) continue; - const amount = toNum(invoice.totalAmount); - const transactionCount = toNum(invoice.transactionCount); + const dueDate = buildDateOnlyStringFromPeriodDay( + invoice.period, + invoice.dueDay, + ); + if (!dueDate) continue; + const amount = toNumber(invoice.totalAmount); + const transactionCount = toNumber(invoice.transactionCount); const paymentStatus = invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING; @@ -319,9 +242,12 @@ export async function fetchDashboardNotifications( if (!shouldInclude) continue; if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue; - const dueDate = calculateDueDate(invoice.period, invoice.dueDay); - const invoiceIsOverdue = isOverdue(dueDate, today); - const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD); + const invoiceIsOverdue = isDateOnlyPast(dueDate, today); + const invoiceIsDueSoon = isDateOnlyWithinDays( + dueDate, + DAYS_THRESHOLD, + today, + ); if (!invoiceIsOverdue && !invoiceIsDueSoon) continue; const notificationId = invoice.invoiceId @@ -343,17 +269,18 @@ export async function fetchDashboardNotifications( // Boletos for (const boleto of boletosRows) { - if (!boleto.dueDate) continue; - const dueDate = - boleto.dueDate instanceof Date - ? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}` - : boleto.dueDate; + const dueDate = toDateOnlyString(boleto.dueDate); + if (!dueDate) continue; - const boletoIsOverdue = isOverdue(dueDate, today); - const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD); + const boletoIsOverdue = isDateOnlyPast(dueDate, today); + const boletoIsDueSoon = isDateOnlyWithinDays( + dueDate, + DAYS_THRESHOLD, + today, + ); const isOldPeriod = boleto.period < currentPeriod; const isCurrentPeriod = boleto.period === currentPeriod; - const amount = toNum(boleto.amount); + const amount = toNumber(boleto.amount); if (isOldPeriod) { notifications.push({ @@ -391,8 +318,8 @@ export async function fetchDashboardNotifications( const budgetNotifications: BudgetNotification[] = []; for (const row of budgetRows) { - const budgetAmount = toNum(row.budgetAmount); - const spentAmount = toNum(row.spentAmount); + const budgetAmount = toNumber(row.budgetAmount); + const spentAmount = toNumber(row.spentAmount); if (budgetAmount <= 0) continue; const usedPercentage = (spentAmount / budgetAmount) * 100; diff --git a/lib/dashboard/pagadores.ts b/lib/dashboard/pagadores.ts index 98d9405..cbd5839 100644 --- a/lib/dashboard/pagadores.ts +++ b/lib/dashboard/pagadores.ts @@ -1,10 +1,10 @@ import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { lancamentos, pagadores } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { calculatePercentageChange } from "@/lib/utils/math"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; import { getPreviousPeriod } from "@/lib/utils/period"; export type DashboardPagador = { diff --git a/lib/dashboard/payment-breakdown-formatters.ts b/lib/dashboard/payment-breakdown-formatters.ts new file mode 100644 index 0000000..bdf372c --- /dev/null +++ b/lib/dashboard/payment-breakdown-formatters.ts @@ -0,0 +1,10 @@ +import { formatPercentage } from "@/lib/utils/percentage"; + +export const formatPaymentBreakdownPercentage = (value: number) => + formatPercentage(value, { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }); + +export const formatPaymentBreakdownTransactionsLabel = (transactions: number) => + `${transactions} ${transactions === 1 ? "lançamento" : "lançamentos"}`; diff --git a/lib/dashboard/payment-overview-tabs.ts b/lib/dashboard/payment-overview-tabs.ts new file mode 100644 index 0000000..24ebba7 --- /dev/null +++ b/lib/dashboard/payment-overview-tabs.ts @@ -0,0 +1,11 @@ +export type PaymentOverviewTab = "conditions" | "methods"; + +export const DEFAULT_PAYMENT_OVERVIEW_TAB: PaymentOverviewTab = "conditions"; + +export const parsePaymentOverviewTab = (value: string): PaymentOverviewTab => { + if (value === "methods") { + return "methods"; + } + + return DEFAULT_PAYMENT_OVERVIEW_TAB; +}; diff --git a/lib/dashboard/payments/payment-conditions.ts b/lib/dashboard/payments/payment-conditions.ts index c1eb0eb..825a54e 100644 --- a/lib/dashboard/payments/payment-conditions.ts +++ b/lib/dashboard/payments/payment-conditions.ts @@ -1,12 +1,12 @@ -import { and, eq, isNull, or, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; + buildDashboardAdminPeriodFilters, + excludeAutoGeneratedEntryNotes, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type PaymentConditionSummary = { condition: string; @@ -37,22 +37,18 @@ export async function fetchPaymentConditions( .from(lancamentos) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), + ...buildDashboardAdminPeriodFilters({ + userId, + period, + adminPagadorId, + }), eq(lancamentos.transactionType, "Despesa"), - eq(lancamentos.pagadorId, adminPagadorId), - or( - isNull(lancamentos.note), - and( - sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, - sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), + excludeAutoGeneratedEntryNotes(), ), ) .groupBy(lancamentos.condition); - const summaries = rows.map((row) => { + const summaries = rows.map((row: (typeof rows)[number]) => { const totalAmount = Math.abs(toNumber(row.totalAmount)); const transactions = Number(row.transactions ?? 0); @@ -63,10 +59,13 @@ export async function fetchPaymentConditions( }; }); - const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0); + const overallTotal = summaries.reduce( + (acc: number, item: (typeof summaries)[number]) => acc + item.amount, + 0, + ); const conditions = summaries - .map((item) => ({ + .map((item: (typeof summaries)[number]) => ({ condition: item.condition, amount: item.amount, transactions: item.transactions, @@ -75,7 +74,10 @@ export async function fetchPaymentConditions( ? Number(((item.amount / overallTotal) * 100).toFixed(2)) : 0, })) - .sort((a, b) => b.amount - a.amount); + .sort( + (a: (typeof summaries)[number], b: (typeof summaries)[number]) => + b.amount - a.amount, + ); return { conditions, diff --git a/lib/dashboard/payments/payment-methods.ts b/lib/dashboard/payments/payment-methods.ts index 38a8583..7616568 100644 --- a/lib/dashboard/payments/payment-methods.ts +++ b/lib/dashboard/payments/payment-methods.ts @@ -1,12 +1,12 @@ -import { and, eq, isNull, or, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; + buildDashboardAdminPeriodFilters, + excludeAutoGeneratedEntryNotes, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type PaymentMethodSummary = { paymentMethod: string; @@ -37,22 +37,18 @@ export async function fetchPaymentMethods( .from(lancamentos) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), + ...buildDashboardAdminPeriodFilters({ + userId, + period, + adminPagadorId, + }), eq(lancamentos.transactionType, "Despesa"), - eq(lancamentos.pagadorId, adminPagadorId), - or( - isNull(lancamentos.note), - and( - sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, - sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), + excludeAutoGeneratedEntryNotes(), ), ) .groupBy(lancamentos.paymentMethod); - const summaries = rows.map((row) => { + const summaries = rows.map((row: (typeof rows)[number]) => { const amount = Math.abs(toNumber(row.totalAmount)); const transactions = Number(row.transactions ?? 0); @@ -63,10 +59,13 @@ export async function fetchPaymentMethods( }; }); - const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0); + const overallTotal = summaries.reduce( + (acc: number, item: (typeof summaries)[number]) => acc + item.amount, + 0, + ); const methods = summaries - .map((item) => ({ + .map((item: (typeof summaries)[number]) => ({ paymentMethod: item.paymentMethod, amount: item.amount, transactions: item.transactions, @@ -75,7 +74,10 @@ export async function fetchPaymentMethods( ? Number(((item.amount / overallTotal) * 100).toFixed(2)) : 0, })) - .sort((a, b) => b.amount - a.amount); + .sort( + (a: (typeof summaries)[number], b: (typeof summaries)[number]) => + b.amount - a.amount, + ); return { methods, diff --git a/lib/dashboard/payments/payment-status.ts b/lib/dashboard/payments/payment-status.ts index 2839a59..8cec7dd 100644 --- a/lib/dashboard/payments/payment-status.ts +++ b/lib/dashboard/payments/payment-status.ts @@ -1,9 +1,12 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, inArray, sql } from "drizzle-orm"; import { lancamentos } from "@/db/schema"; -import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; +import { + buildDashboardAdminPeriodFilters, + excludeAutoInvoiceEntries, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type PaymentStatusCategory = { total: number; @@ -51,11 +54,13 @@ export async function fetchPaymentStatus( .from(lancamentos) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.pagadorId, adminPagadorId), + ...buildDashboardAdminPeriodFilters({ + userId, + period, + adminPagadorId, + }), inArray(lancamentos.transactionType, ["Receita", "Despesa"]), - sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`, + excludeAutoInvoiceEntries(), ), ) .groupBy(lancamentos.transactionType); diff --git a/lib/dashboard/purchases-by-category.ts b/lib/dashboard/purchases-by-category.ts index 2d266c0..ffc0500 100644 --- a/lib/dashboard/purchases-by-category.ts +++ b/lib/dashboard/purchases-by-category.ts @@ -1,12 +1,12 @@ -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { cartoes, categorias, contas, lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; + buildDashboardAdminPeriodFilters, + excludeAutoGeneratedEntryNotes, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type CategoryOption = { id: string; @@ -68,19 +68,13 @@ export async function fetchPurchasesByCategory( .leftJoin(contas, eq(lancamentos.contaId, contas.id)) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.pagadorId, adminPagadorId), + ...buildDashboardAdminPeriodFilters({ + userId, + period, + adminPagadorId, + }), inArray(categorias.type, ["despesa", "receita"]), - or( - isNull(lancamentos.note), - and( - sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), + excludeAutoGeneratedEntryNotes(), ), ) .orderBy(desc(lancamentos.purchaseDate)); diff --git a/lib/dashboard/top-establishments.ts b/lib/dashboard/top-establishments.ts index 93d5667..3fd9d62 100644 --- a/lib/dashboard/top-establishments.ts +++ b/lib/dashboard/top-establishments.ts @@ -1,12 +1,12 @@ -import { and, eq, isNull, or, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { cartoes, contas, lancamentos } from "@/db/schema"; import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; + buildDashboardAdminPeriodFilters, + excludeAutoGeneratedEntryNotes, +} from "@/lib/dashboard/lancamento-filters"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; export type TopEstablishment = { id: string; @@ -55,17 +55,13 @@ export async function fetchTopEstablishments( .leftJoin(contas, eq(lancamentos.contaId, contas.id)) .where( and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), + ...buildDashboardAdminPeriodFilters({ + userId, + period, + adminPagadorId, + }), eq(lancamentos.transactionType, "Despesa"), - eq(lancamentos.pagadorId, adminPagadorId), - or( - isNull(lancamentos.note), - and( - sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, - sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), + excludeAutoGeneratedEntryNotes(), ), ) .groupBy(lancamentos.name) @@ -76,9 +72,11 @@ export async function fetchTopEstablishments( .limit(10); const establishments = rows - .filter((row) => shouldIncludeEstablishment(row.name)) + .filter((row: (typeof rows)[number]) => + shouldIncludeEstablishment(row.name), + ) .map( - (row): TopEstablishment => ({ + (row: (typeof rows)[number]): TopEstablishment => ({ id: row.name, name: row.name, amount: Math.abs(toNumber(row.totalAmount)), diff --git a/lib/dashboard/use-bill-widget-controller.ts b/lib/dashboard/use-bill-widget-controller.ts new file mode 100644 index 0000000..6a31463 --- /dev/null +++ b/lib/dashboard/use-bill-widget-controller.ts @@ -0,0 +1,46 @@ +"use client"; + +import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions"; +import type { DashboardBill } from "@/lib/dashboard/bills"; +import { + type BillDialogState, + getCurrentBillDateString, + markBillAsSettled, +} from "@/lib/dashboard/bills-helpers"; +import { + type PaymentDialogController, + usePaymentDialogController, +} from "@/lib/dashboard/use-payment-dialog-controller"; + +const EMPTY_BILLS: DashboardBill[] = []; + +export type BillWidgetController = Omit< + PaymentDialogController, + "selectedItem" +> & { + selectedBill: DashboardBill | null; + modalState: BillDialogState; +}; + +export function useBillWidgetController( + bills?: DashboardBill[], +): BillWidgetController { + const safeBills = bills ?? EMPTY_BILLS; + const controller = usePaymentDialogController({ + items: safeBills, + getItemId: (bill) => bill.id, + isItemConfirmed: (bill) => bill.isSettled, + executeConfirm: (bill) => + toggleLancamentoSettlementAction({ + id: bill.id, + value: true, + }), + applyConfirmedState: (bill) => + markBillAsSettled(bill, getCurrentBillDateString()), + }); + + return { + ...controller, + selectedBill: controller.selectedItem, + }; +} diff --git a/lib/dashboard/use-goals-progress-widget-controller.ts b/lib/dashboard/use-goals-progress-widget-controller.ts new file mode 100644 index 0000000..7f3f50d --- /dev/null +++ b/lib/dashboard/use-goals-progress-widget-controller.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Budget, BudgetCategory } from "@/components/orcamentos/types"; +import type { + GoalProgressItem, + GoalsProgressData, +} from "@/lib/dashboard/goals-progress"; +import { + mapGoalProgressCategoriesToBudgetCategories, + mapGoalProgressItemToBudget, +} from "@/lib/dashboard/goals-progress-helpers"; + +export type GoalsProgressWidgetController = { + selectedBudget: Budget | null; + editOpen: boolean; + categories: BudgetCategory[]; + defaultPeriod: string; + handleEdit: (item: GoalProgressItem) => void; + handleEditOpenChange: (open: boolean) => void; +}; + +export function useGoalsProgressWidgetController( + data: GoalsProgressData, +): GoalsProgressWidgetController { + const [editOpen, setEditOpen] = useState(false); + const [selectedBudget, setSelectedBudget] = useState(null); + + const categories = useMemo( + () => mapGoalProgressCategoriesToBudgetCategories(data.categories), + [data.categories], + ); + + const defaultPeriod = data.items[0]?.period ?? ""; + + const handleEdit = (item: GoalProgressItem) => { + setSelectedBudget(mapGoalProgressItemToBudget(item)); + setEditOpen(true); + }; + + const handleEditOpenChange = (open: boolean) => { + setEditOpen(open); + if (!open) { + setSelectedBudget(null); + } + }; + + return { + selectedBudget, + editOpen, + categories, + defaultPeriod, + handleEdit, + handleEditOpenChange, + }; +} diff --git a/lib/dashboard/use-invoices-widget-controller.ts b/lib/dashboard/use-invoices-widget-controller.ts new file mode 100644 index 0000000..7d5c74c --- /dev/null +++ b/lib/dashboard/use-invoices-widget-controller.ts @@ -0,0 +1,46 @@ +"use client"; + +import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions"; +import type { DashboardInvoice } from "@/lib/dashboard/invoices"; +import { + getCurrentDateString, + type InvoiceDialogState, + isInvoicePaid, + markInvoiceAsPaid, +} from "@/lib/dashboard/invoices-helpers"; +import { + type PaymentDialogController, + usePaymentDialogController, +} from "@/lib/dashboard/use-payment-dialog-controller"; +import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas"; + +export type InvoicesWidgetController = Omit< + PaymentDialogController, + "selectedItem" +> & { + selectedInvoice: DashboardInvoice | null; + modalState: InvoiceDialogState; +}; + +export function useInvoicesWidgetController( + invoices: DashboardInvoice[], +): InvoicesWidgetController { + const controller = usePaymentDialogController({ + items: invoices, + getItemId: (invoice) => invoice.id, + isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus), + executeConfirm: (invoice) => + updateInvoicePaymentStatusAction({ + cartaoId: invoice.cardId, + period: invoice.period, + status: INVOICE_PAYMENT_STATUS.PAID, + }), + applyConfirmedState: (invoice) => + markInvoiceAsPaid(invoice, getCurrentDateString()), + }); + + return { + ...controller, + selectedInvoice: controller.selectedItem, + }; +} diff --git a/lib/dashboard/use-notes-widget-controller.ts b/lib/dashboard/use-notes-widget-controller.ts new file mode 100644 index 0000000..29f4ebc --- /dev/null +++ b/lib/dashboard/use-notes-widget-controller.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Note } from "@/components/anotacoes/types"; +import type { DashboardNote } from "@/lib/dashboard/notes"; +import { mapDashboardNotesToNotes } from "@/lib/dashboard/notes-mappers"; + +export type NotesWidgetController = { + mappedNotes: Note[]; + noteToEdit: Note | null; + isEditOpen: boolean; + noteDetails: Note | null; + isDetailsOpen: boolean; + openEdit: (note: Note) => void; + openDetails: (note: Note) => void; + handleEditOpenChange: (open: boolean) => void; + handleDetailsOpenChange: (open: boolean) => void; +}; + +export function useNotesWidgetController( + notes: DashboardNote[], +): NotesWidgetController { + const [noteToEdit, setNoteToEdit] = useState(null); + const [isEditOpen, setIsEditOpen] = useState(false); + const [noteDetails, setNoteDetails] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + + const mappedNotes = useMemo(() => mapDashboardNotesToNotes(notes), [notes]); + + const openEdit = (note: Note) => { + setNoteToEdit(note); + setIsEditOpen(true); + }; + + const openDetails = (note: Note) => { + setNoteDetails(note); + setIsDetailsOpen(true); + }; + + const handleEditOpenChange = (open: boolean) => { + setIsEditOpen(open); + if (!open) { + setNoteToEdit(null); + } + }; + + const handleDetailsOpenChange = (open: boolean) => { + setIsDetailsOpen(open); + if (!open) { + setNoteDetails(null); + } + }; + + return { + mappedNotes, + noteToEdit, + isEditOpen, + noteDetails, + isDetailsOpen, + openEdit, + openDetails, + handleEditOpenChange, + handleDetailsOpenChange, + }; +} diff --git a/lib/dashboard/use-payment-dialog-controller.ts b/lib/dashboard/use-payment-dialog-controller.ts new file mode 100644 index 0000000..1980861 --- /dev/null +++ b/lib/dashboard/use-payment-dialog-controller.ts @@ -0,0 +1,110 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import type { ActionResult } from "@/lib/types/actions"; + +export type PaymentDialogState = "idle" | "processing" | "success"; + +type UsePaymentDialogControllerOptions = { + items: TItem[]; + getItemId: (item: TItem) => string; + isItemConfirmed: (item: TItem) => boolean; + executeConfirm: (item: TItem) => Promise; + applyConfirmedState: (item: TItem) => TItem; +}; + +export type PaymentDialogController = { + items: TItem[]; + selectedItem: TItem | null; + isModalOpen: boolean; + modalState: PaymentDialogState; + isPending: boolean; + openPaymentDialog: (itemId: string) => void; + closePaymentDialog: () => void; + confirmPayment: () => void; +}; + +export function usePaymentDialogController({ + items, + getItemId, + isItemConfirmed, + executeConfirm, + applyConfirmedState, +}: UsePaymentDialogControllerOptions): PaymentDialogController { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [localItems, setLocalItems] = useState(items); + const [selectedId, setSelectedId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalState, setModalState] = useState("idle"); + + useEffect(() => { + setLocalItems(items); + }, [items]); + + const selectedItem = useMemo( + () => localItems.find((item) => getItemId(item) === selectedId) ?? null, + [localItems, selectedId, getItemId], + ); + + const openPaymentDialog = (itemId: string) => { + setSelectedId(itemId); + setModalState("idle"); + setIsModalOpen(true); + }; + + const closePaymentDialog = () => { + setIsModalOpen(false); + setSelectedId(null); + setModalState("idle"); + }; + + const confirmPayment = () => { + const itemToUpdate = selectedItem; + if ( + !itemToUpdate || + isItemConfirmed(itemToUpdate) || + modalState === "processing" || + isPending + ) { + return; + } + + const itemId = getItemId(itemToUpdate); + setModalState("processing"); + + startTransition(() => { + void (async () => { + const result = await executeConfirm(itemToUpdate); + + if (!result.success) { + toast.error(result.error); + setModalState("idle"); + return; + } + + setLocalItems((previous) => + previous.map((item) => + getItemId(item) === itemId ? applyConfirmedState(item) : item, + ), + ); + toast.success(result.message); + router.refresh(); + setModalState("success"); + })(); + }); + }; + + return { + items: localItems, + selectedItem, + isModalOpen, + modalState, + isPending, + openPaymentDialog, + closePaymentDialog, + confirmPayment, + }; +} diff --git a/lib/dashboard/use-payment-overview-widget-controller.ts b/lib/dashboard/use-payment-overview-widget-controller.ts new file mode 100644 index 0000000..2596a31 --- /dev/null +++ b/lib/dashboard/use-payment-overview-widget-controller.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useState } from "react"; +import { + DEFAULT_PAYMENT_OVERVIEW_TAB, + type PaymentOverviewTab, + parsePaymentOverviewTab, +} from "@/lib/dashboard/payment-overview-tabs"; + +export type PaymentOverviewWidgetController = { + activeTab: PaymentOverviewTab; + handleTabChange: (value: string) => void; +}; + +export function usePaymentOverviewWidgetController(): PaymentOverviewWidgetController { + const [activeTab, setActiveTab] = useState( + DEFAULT_PAYMENT_OVERVIEW_TAB, + ); + + const handleTabChange = (value: string) => { + setActiveTab(parsePaymentOverviewTab(value)); + }; + + return { + activeTab, + handleTabChange, + }; +} diff --git a/lib/dashboard/widgets/widgets-config.tsx b/lib/dashboard/widgets/widgets-config.tsx index bbb1d42..08aa4f2 100644 --- a/lib/dashboard/widgets/widgets-config.tsx +++ b/lib/dashboard/widgets/widgets-config.tsx @@ -16,16 +16,16 @@ import { } from "@remixicon/react"; import Link from "next/link"; import type { ReactNode } from "react"; -import { BoletosWidget } from "@/components/dashboard/boletos-widget"; -import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget"; +import { BillWidget } from "@/components/dashboard/bill-widget"; import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart"; import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget"; import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart"; import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget"; +import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget"; import { InvoicesWidget } from "@/components/dashboard/invoices-widget"; import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget"; import { NotesWidget } from "@/components/dashboard/notes-widget"; -import { PagadoresWidget } from "@/components/dashboard/pagadores-widget"; +import { PayersWidget } from "@/components/dashboard/payers-widget"; import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget"; import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget"; import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget"; @@ -70,9 +70,7 @@ export const widgetsConfig: WidgetConfig[] = [ title: "Boletos", subtitle: "Controle de boletos do período", icon: , - component: ({ data }) => ( - - ), + component: ({ data }) => , }, { id: "payment-status", @@ -98,7 +96,7 @@ export const widgetsConfig: WidgetConfig[] = [ subtitle: "Despesas por pagador no período", icon: , component: ({ data }) => ( - + ), action: (