From 549a5bdba1bdc2320b358e5fa4f7c9f810e1c1de Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Fri, 3 Apr 2026 18:10:43 +0000 Subject: [PATCH] =?UTF-8?q?fix(financeiro):=20alinhar=20saldo,=20m=C3=A9tr?= =?UTF-8?q?icas=20e=20relat=C3=B3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db/schema.ts | 1 + src/features/dashboard/bills-helpers.ts | 9 ++ src/features/dashboard/bills-queries.ts | 84 ++++++++++- .../expenses-by-category-queries.ts | 13 +- .../categories/income-by-category-queries.ts | 2 + .../dashboard/category-overview-queries.ts | 2 + .../components/bills/bill-list-item.tsx | 47 +++++-- .../components/bills/bill-payment-dialog.tsx | 4 +- .../components/dashboard-grid-editable.tsx | 15 +- .../components/dashboard-metrics-cards.tsx | 51 ++++++- .../components/invoices/invoice-list-item.tsx | 47 ++++++- .../invoices/invoice-payment-dialog.tsx | 4 +- .../components/metrics-card-info-button.tsx | 44 ++++++ .../components/my-accounts-widget.tsx | 131 ++++++++++++++++-- .../current-period-overview-queries.ts | 93 +++++++++++-- .../dashboard/dashboard-metrics-queries.ts | 27 +++- .../income-expense-balance-queries.ts | 45 ++++-- src/features/dashboard/invoices-helpers.ts | 22 +++ src/features/dashboard/invoices-queries.ts | 78 ++++++++++- src/features/dashboard/payers-queries.ts | 8 +- .../payments/payment-status-queries.ts | 12 +- .../dashboard/period-overview-queries.ts | 26 +++- src/features/dashboard/transaction-filters.ts | 2 + src/features/dashboard/widgets/actions.ts | 74 ++++++---- .../dashboard/widgets/widgets-config.tsx | 17 ++- src/features/insights/actions/aggregate.ts | 48 ++++++- .../reports/category-chart-queries.ts | 8 +- .../reports/category-report-queries.ts | 8 +- .../reports/establishments/queries.ts | 2 + .../dashboard-metrics-cards-skeleton.tsx | 46 +++--- src/shared/lib/accounts/query-filters.ts | 9 ++ src/shared/utils/financial-dates.ts | 99 +++++++++++++ 32 files changed, 960 insertions(+), 118 deletions(-) create mode 100644 src/features/dashboard/components/metrics-card-info-button.tsx create mode 100644 src/shared/lib/accounts/query-filters.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index 79e537b..7fc9e5c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -139,6 +139,7 @@ export const userPreferences = pgTable("preferencias_usuario", { dashboardWidgets: jsonb("dashboard_widgets").$type<{ order: string[]; hidden: string[]; + myAccountsShowExcluded?: boolean; }>(), createdAt: timestamp("created_at", { mode: "date", diff --git a/src/features/dashboard/bills-helpers.ts b/src/features/dashboard/bills-helpers.ts index 11bfd12..7b6badf 100644 --- a/src/features/dashboard/bills-helpers.ts +++ b/src/features/dashboard/bills-helpers.ts @@ -3,6 +3,7 @@ import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date"; import { buildFinancialStatusLabel, + buildRelativeFinancialStatusLabel, formatFinancialDateLabel, } from "@/shared/utils/financial-dates"; @@ -24,6 +25,14 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => { }); }; +export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => { + return buildRelativeFinancialStatusLabel({ + isSettled: bill.isSettled, + dueDate: bill.dueDate, + paidAt: bill.boletoPaymentDate, + }); +}; + export const getCurrentBillDateString = () => getBusinessDateString(); export const isBillOverdue = (bill: DashboardBill) => { diff --git a/src/features/dashboard/bills-queries.ts b/src/features/dashboard/bills-queries.ts index 6ed5efd..cd7ecda 100644 --- a/src/features/dashboard/bills-queries.ts +++ b/src/features/dashboard/bills-queries.ts @@ -1,10 +1,15 @@ "use server"; -import { and, asc, eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { transactions } from "@/db/schema"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; -import { toDateOnlyString } from "@/shared/utils/date"; +import { + compareDateOnly, + getBusinessDateString, + isDateOnlyPast, + toDateOnlyString, +} from "@/shared/utils/date"; import { safeToNumber as toNumber } from "@/shared/utils/number"; const PAYMENT_METHOD_BOLETO = "Boleto"; @@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = { pendingCount: number; }; +const compareDateOnlyAscWithNullsLast = ( + left: string | null, + right: string | null, +) => { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return compareDateOnly(left, right); +}; + +const compareDateOnlyDescWithNullsLast = ( + left: string | null, + right: string | null, +) => { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return compareDateOnly(right, left); +}; + export async function fetchDashboardBills( userId: string, period: string, ): Promise { + const today = getBusinessDateString(); const adminPayerId = await getAdminPayerId(userId); if (!adminPayerId) { return { bills: [], totalPendingAmount: 0, pendingCount: 0 }; @@ -59,11 +85,6 @@ export async function fetchDashboardBills( eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO), eq(transactions.payerId, adminPayerId), ), - ) - .orderBy( - asc(transactions.isSettled), - asc(transactions.dueDate), - asc(transactions.name), ); const bills = rows.map((row: RawDashboardBill): DashboardBill => { @@ -78,6 +99,55 @@ export async function fetchDashboardBills( }; }); + bills.sort((a, b) => { + if (a.isSettled !== b.isSettled) { + return a.isSettled ? 1 : -1; + } + + if (!a.isSettled && !b.isSettled) { + const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false; + const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false; + + if (aIsOverdue !== bIsOverdue) { + return aIsOverdue ? -1 : 1; + } + + const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate); + if (dueDateDiff !== 0) { + return dueDateDiff; + } + + const amountDiff = b.amount - a.amount; + if (amountDiff !== 0) { + return amountDiff; + } + } + + if (a.isSettled && b.isSettled) { + const paidAtDiff = compareDateOnlyDescWithNullsLast( + a.boletoPaymentDate, + b.boletoPaymentDate, + ); + if (paidAtDiff !== 0) { + return paidAtDiff; + } + + const amountDiff = b.amount - a.amount; + if (amountDiff !== 0) { + return amountDiff; + } + } + + const nameDiff = a.name.localeCompare(b.name, "pt-BR", { + sensitivity: "base", + }); + if (nameDiff !== 0) { + return nameDiff; + } + + return a.id.localeCompare(b.id); + }); + let totalPendingAmount = 0; let pendingCount = 0; diff --git a/src/features/dashboard/categories/expenses-by-category-queries.ts b/src/features/dashboard/categories/expenses-by-category-queries.ts index ef37b43..1a44527 100644 --- a/src/features/dashboard/categories/expenses-by-category-queries.ts +++ b/src/features/dashboard/categories/expenses-by-category-queries.ts @@ -1,5 +1,10 @@ import { and, eq, inArray, sql } from "drizzle-orm"; -import { budgets, categories, transactions } from "@/db/schema"; +import { + budgets, + categories, + financialAccounts, + transactions, +} from "@/db/schema"; import { buildCategoryBreakdownData, type DashboardCategoryBreakdownData, @@ -8,6 +13,7 @@ import { import { buildDashboardAdminFilters, excludeAutoInvoiceEntries, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -39,6 +45,10 @@ export async function fetchExpensesByCategory( }) .from(transactions) .innerJoin(categories, eq(transactions.categoryId, categories.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( ...buildDashboardAdminFilters({ userId, adminPayerId }), @@ -46,6 +56,7 @@ export async function fetchExpensesByCategory( eq(transactions.transactionType, "Despesa"), eq(categories.type, "despesa"), excludeAutoInvoiceEntries(), + excludeTransactionsFromExcludedAccounts(), ), ) .groupBy( diff --git a/src/features/dashboard/categories/income-by-category-queries.ts b/src/features/dashboard/categories/income-by-category-queries.ts index 7c2084b..d59d343 100644 --- a/src/features/dashboard/categories/income-by-category-queries.ts +++ b/src/features/dashboard/categories/income-by-category-queries.ts @@ -14,6 +14,7 @@ import { buildDashboardAdminFilters, excludeAutoInvoiceEntries, excludeInitialBalanceWhenConfigured, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -57,6 +58,7 @@ export async function fetchIncomeByCategory( eq(categories.type, "receita"), excludeAutoInvoiceEntries(), excludeInitialBalanceWhenConfigured(), + excludeTransactionsFromExcludedAccounts(), ), ) .groupBy( diff --git a/src/features/dashboard/category-overview-queries.ts b/src/features/dashboard/category-overview-queries.ts index 3c06258..bfdcd9d 100644 --- a/src/features/dashboard/category-overview-queries.ts +++ b/src/features/dashboard/category-overview-queries.ts @@ -20,6 +20,7 @@ import { buildDashboardAdminFilters, excludeAutoInvoiceEntries, excludeInitialBalanceWhenConfigured, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview( and( ...buildDashboardAdminFilters({ userId, adminPayerId }), inArray(transactions.period, [period, previousPeriod]), + excludeTransactionsFromExcludedAccounts(), or( and( eq(transactions.transactionType, "Despesa"), diff --git a/src/features/dashboard/components/bills/bill-list-item.tsx b/src/features/dashboard/components/bills/bill-list-item.tsx index f376d55..bda3a49 100644 --- a/src/features/dashboard/components/bills/bill-list-item.tsx +++ b/src/features/dashboard/components/bills/bill-list-item.tsx @@ -1,12 +1,18 @@ import { RiCheckboxCircleFill } from "@remixicon/react"; import { buildBillStatusLabel, + buildBillWidgetStatusLabel, isBillOverdue, } from "@/features/dashboard/bills-helpers"; import type { DashboardBill } from "@/features/dashboard/bills-queries"; import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import MoneyValues from "@/shared/components/money-values"; import { Button } from "@/shared/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; import { cn } from "@/shared/utils/ui"; type BillListItemProps = { @@ -15,8 +21,13 @@ type BillListItemProps = { }; export function BillListItem({ bill, onPay }: BillListItemProps) { - const statusLabel = buildBillStatusLabel(bill); + const statusLabel = buildBillWidgetStatusLabel(bill); + const absoluteStatusLabel = buildBillStatusLabel(bill); const overdue = isBillOverdue(bill); + const statusTooltipLabel = + statusLabel && statusLabel !== absoluteStatusLabel + ? absoluteStatusLabel + : null; return (
  • @@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
    {statusLabel ? ( - - {statusLabel} - + statusTooltipLabel ? ( + + + + {statusLabel} + + + + {statusTooltipLabel} + + + ) : ( + + {statusLabel} + + ) ) : null}
    diff --git a/src/features/dashboard/components/bills/bill-payment-dialog.tsx b/src/features/dashboard/components/bills/bill-payment-dialog.tsx index 9d02c8f..62f81fd 100644 --- a/src/features/dashboard/components/bills/bill-payment-dialog.tsx +++ b/src/features/dashboard/components/bills/bill-payment-dialog.tsx @@ -56,7 +56,7 @@ export function BillPaymentDialog({ }} > { if (isProcessing) { event.preventDefault(); @@ -93,7 +93,7 @@ export function BillPaymentDialog({ {bill ? (
    {/* Card principal */} -
    +

    Boleto

    diff --git a/src/features/dashboard/components/dashboard-grid-editable.tsx b/src/features/dashboard/components/dashboard-grid-editable.tsx index 5a1a6f1..5b2abe4 100644 --- a/src/features/dashboard/components/dashboard-grid-editable.tsx +++ b/src/features/dashboard/components/dashboard-grid-editable.tsx @@ -76,6 +76,9 @@ export function DashboardGridEditable({ const [hiddenWidgets, setHiddenWidgets] = useState( initialPreferences?.hidden ?? [], ); + const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState( + initialPreferences?.myAccountsShowExcluded ?? true, + ); // Keep track of original state for cancel const [originalOrder, setOriginalOrder] = useState(widgetOrder); @@ -186,6 +189,7 @@ export function DashboardGridEditable({ if (result.success) { setWidgetOrder(DEFAULT_WIDGET_ORDER); setHiddenWidgets([]); + setMyAccountsShowExcluded(true); toast.success("Preferências restauradas!"); } else { toast.error(result.error ?? "Erro ao restaurar"); @@ -361,7 +365,16 @@ export function DashboardGridEditable({ icon={widget.icon} action={widget.action} > - {widget.component({ data, period })} + {widget.component({ + data, + period, + widgetPreferences: { + order: widgetOrder, + hidden: hiddenWidgets, + myAccountsShowExcluded, + }, + onMyAccountsShowExcludedChange: setMyAccountsShowExcluded, + })}
    diff --git a/src/features/dashboard/components/dashboard-metrics-cards.tsx b/src/features/dashboard/components/dashboard-metrics-cards.tsx index 441641d..30b03d6 100644 --- a/src/features/dashboard/components/dashboard-metrics-cards.tsx +++ b/src/features/dashboard/components/dashboard-metrics-cards.tsx @@ -7,6 +7,7 @@ import { RiScalesLine, RiSubtractLine, } from "@remixicon/react"; +import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button"; import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; import MoneyValues from "@/shared/components/money-values"; import { @@ -36,6 +37,14 @@ const CARDS = [ icon: RiArrowDownLine, invertTrend: false, iconClass: "text-success", + helpTitle: "Como calculamos receitas", + helpLines: [ + "Somamos os lançamentos do tipo Receita no período selecionado.", + "Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).", + "Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.", + "Não entram transferências internas nem lançamentos automáticos de fatura.", + "Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.", + ], }, { label: "Despesas", @@ -44,14 +53,29 @@ const CARDS = [ icon: RiArrowUpLine, invertTrend: true, iconClass: "text-destructive", + helpTitle: "Como calculamos despesas", + helpLines: [ + "Somamos os lançamentos do tipo Despesa no período selecionado.", + "Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).", + "Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.", + "Não entram transferências internas nem lançamentos automáticos de fatura.", + "O valor mostrado é a saída efetiva do período, sempre em número positivo no card.", + ], }, { label: "Balanço", - subtitle: "Receitas menos despesas", + subtitle: "Receitas, despesas e ajustes entre contas", key: "balanco", icon: RiScalesLine, invertTrend: false, iconClass: "text-warning", + helpTitle: "Como calculamos o balanço", + helpLines: [ + "Partimos de receitas menos despesas do período.", + "Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.", + "Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.", + "Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.", + ], }, { label: "Previsto", @@ -60,6 +84,13 @@ const CARDS = [ icon: RiCalendarCheckLine, invertTrend: false, iconClass: "text-cyan-600", + helpTitle: "Como calculamos o previsto", + helpLines: [ + "Acumulamos o balanço mês a mês até o período atual.", + "Ele usa a mesma regra do card de balanço em cada mês do histórico.", + "Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora desse acumulado.", + "Por isso também reflete ajustes de transferências entre contas consideradas e não consideradas.", + ], }, ] as const; @@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) { return (
    {CARDS.map( - ({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => { + ({ + label, + subtitle, + key, + icon: Icon, + invertTrend, + iconClass, + helpTitle, + helpLines, + }) => { const metric = metrics[key]; const trend = getTrend(metric.current, metric.previous); const TrendIcon = TREND_ICONS[trend]; @@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
    - + {label} + {subtitle} diff --git a/src/features/dashboard/components/invoices/invoice-list-item.tsx b/src/features/dashboard/components/invoices/invoice-list-item.tsx index 0838f0b..5f7d3da 100644 --- a/src/features/dashboard/components/invoices/invoice-list-item.tsx +++ b/src/features/dashboard/components/invoices/invoice-list-item.tsx @@ -4,8 +4,10 @@ import { buildInvoiceDetailsHref, buildInvoiceInitials, formatInvoicePaymentDate, + formatInvoiceWidgetPaymentDate, getInvoiceShareLabel, parseInvoiceDueDate, + parseInvoiceWidgetDueDate, } from "@/features/dashboard/invoices-helpers"; import type { DashboardInvoice } from "@/features/dashboard/invoices-queries"; import MoneyValues from "@/shared/components/money-values"; @@ -20,6 +22,11 @@ import { HoverCardContent, HoverCardTrigger, } from "@/shared/components/ui/hover-card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { isDateOnlyPast } from "@/shared/utils/date"; @@ -31,14 +38,22 @@ type InvoiceListItemProps = { }; export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) { - const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay); + const dueInfo = parseInvoiceWidgetDueDate(invoice.period, invoice.dueDay); + const absoluteDueInfo = 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 paymentInfo = formatInvoiceWidgetPaymentDate(invoice.paidAt); + const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt); const breakdown = invoice.pagadorBreakdown ?? []; const hasBreakdown = breakdown.length > 0; const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period); + const dueTooltipLabel = + dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null; + const paymentTooltipLabel = + paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label + ? absolutePaymentInfo?.label + : null; const linkNode = ( - {!isPaid ? {dueInfo.label} : null} + {!isPaid ? ( + dueTooltipLabel ? ( + + + {dueInfo.label} + + {dueTooltipLabel} + + ) : ( + {dueInfo.label} + ) + ) : null} {isPaid && paymentInfo ? ( - {paymentInfo.label} + paymentTooltipLabel ? ( + + + + {paymentInfo.label} + + + + {paymentTooltipLabel} + + + ) : ( + {paymentInfo.label} + ) ) : null}
    diff --git a/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx b/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx index be45af8..b369e90 100644 --- a/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx +++ b/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx @@ -63,7 +63,7 @@ export function InvoicePaymentDialog({ }} > { if (isProcessing) { event.preventDefault(); @@ -100,7 +100,7 @@ export function InvoicePaymentDialog({ {invoice ? (
    {/* Card principal */} -
    +
    + + + + +
    +

    {helpTitle}

    +
    +
      + {helpLines.map((line) => ( +
    • {line}
    • + ))} +
    +
    + + ); +} diff --git a/src/features/dashboard/components/my-accounts-widget.tsx b/src/features/dashboard/components/my-accounts-widget.tsx index abc5c9b..eedddbe 100644 --- a/src/features/dashboard/components/my-accounts-widget.tsx +++ b/src/features/dashboard/components/my-accounts-widget.tsx @@ -1,39 +1,123 @@ -import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react"; +"use client"; + +import { + RiBarChartBoxLine, + RiExternalLinkLine, + RiEyeLine, + RiEyeOffLine, +} from "@remixicon/react"; import Image from "next/image"; import Link from "next/link"; +import { useTransition } from "react"; +import { toast } from "sonner"; import type { DashboardAccount } from "@/features/dashboard/accounts-queries"; +import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widgets/actions"; import MoneyValues from "@/shared/components/money-values"; +import { Badge } from "@/shared/components/ui/badge"; +import { Button } from "@/shared/components/ui/button"; import { CardFooter } from "@/shared/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; import { WidgetEmptyState } from "@/shared/components/widget-empty-state"; import { resolveLogoSrc } from "@/shared/lib/logo"; import { formatPeriodForUrl } from "@/shared/utils/period"; type MyAccountsWidgetProps = { accounts: DashboardAccount[]; + showExcludedAccounts: boolean; + onShowExcludedAccountsChange?: (value: boolean) => void; totalBalance: number; period: string; }; export function MyAccountsWidget({ accounts, + showExcludedAccounts, + onShowExcludedAccountsChange, totalBalance, period, }: MyAccountsWidgetProps) { - const visibleAccounts = accounts.filter( - (account) => !account.excludeFromBalance, - ); + const [isPending, startTransition] = useTransition(); + + const excludedAccountsCount = accounts.filter( + (account) => account.excludeFromBalance, + ).length; + const visibleAccounts = showExcludedAccounts + ? accounts + : accounts.filter((account) => !account.excludeFromBalance); const displayedAccounts = visibleAccounts.slice(0, 5); const remainingCount = visibleAccounts.length - displayedAccounts.length; + const hiddenExcludedAccountsCount = showExcludedAccounts + ? 0 + : excludedAccountsCount; + const toggleButtonLabel = showExcludedAccounts + ? "Ocultar contas não consideradas" + : "Mostrar contas não consideradas"; + + const handleToggleExcludedAccounts = () => { + const nextShowExcludedAccounts = !showExcludedAccounts; + onShowExcludedAccountsChange?.(nextShowExcludedAccounts); + + startTransition(async () => { + const result = await updateMyAccountsWidgetPreference({ + showExcludedAccounts: nextShowExcludedAccounts, + }); + + if (!result.success) { + onShowExcludedAccountsChange?.(!nextShowExcludedAccounts); + toast.error(result.error ?? "Erro ao salvar preferência"); + } + }); + }; return ( <> -
    - Saldo Total - +
    +
    +

    Saldo Total

    + +
    + + {excludedAccountsCount > 0 ? ( + + + + + +

    {toggleButtonLabel}

    +
    +
    + ) : null}
    + {hiddenExcludedAccountsCount > 0 ? ( +

    + {hiddenExcludedAccountsCount}{" "} + {hiddenExcludedAccountsCount === 1 + ? "conta não considerada oculta" + : "contas não consideradas ocultas"} +

    + ) : null} +
    - {displayedAccounts.length === 0 ? ( + {accounts.length === 0 ? (
    + ) : displayedAccounts.length === 0 ? ( +
    + } + title="As contas não consideradas estão ocultas" + description="Use o botão no topo do widget para mostrá-las novamente." + /> +
    ) : (
      {displayedAccounts.map((account) => { @@ -60,6 +152,7 @@ export function MyAccountsWidget({ src={logoSrc} alt={`Logo da conta ${account.name}`} fill + sizes="38px" className="object-contain rounded-full" /> ) : null} @@ -79,6 +172,26 @@ export function MyAccountsWidget({ aria-hidden /> + + {account.excludeFromBalance ? ( + + + + + Não considerada + + + + +

      + Esta conta aparece na lista, mas não entra no + cálculo do saldo total porque está marcada para + desconsiderar do saldo total. +

      +
      +
      + ) : null} +
      {account.accountType}
      @@ -95,7 +208,7 @@ export function MyAccountsWidget({ )}
    - {visibleAccounts.length > displayedAccounts.length ? ( + {remainingCount > 0 ? ( +{remainingCount} contas não exibidas diff --git a/src/features/dashboard/current-period-overview-queries.ts b/src/features/dashboard/current-period-overview-queries.ts index 6bca371..a784945 100644 --- a/src/features/dashboard/current-period-overview-queries.ts +++ b/src/features/dashboard/current-period-overview-queries.ts @@ -17,12 +17,18 @@ import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-m import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries"; import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries"; import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries"; +import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/shared/lib/accounts/constants"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; +import { + compareDateOnly, + getBusinessDateString, + isDateOnlyPast, +} from "@/shared/utils/date"; import { safeToNumber as toNumber } from "@/shared/utils/number"; const PAYMENT_METHOD_BOLETO = "Boleto"; @@ -54,6 +60,7 @@ type CurrentPeriodTransactionRow = { categoryType: string | null; cardLogo: string | null; accountLogo: string | null; + accountExcludeInitialBalanceFromIncome: boolean | null; }; type CategoryOption = PurchasesByCategoryData["categories"][number]; @@ -112,6 +119,21 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) => const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) => !isInitialBalanceNote(note) && !isAutoInvoiceNote(note); +const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => { + if (!shouldIncludeWithoutAutoInvoice(row.note)) { + return false; + } + + if ( + isInitialBalanceNote(row.note) && + row.accountExcludeInitialBalanceFromIncome === true + ) { + return false; + } + + return true; +}; + const shouldIncludeNamedItem = (name: string) => { const normalized = name.trim().toLowerCase(); @@ -126,9 +148,30 @@ const shouldIncludeNamedItem = (name: string) => { return true; }; +const compareDateOnlyAscWithNullsLast = ( + left: string | null, + right: string | null, +) => { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return compareDateOnly(left, right); +}; + +const compareDateOnlyDescWithNullsLast = ( + left: string | null, + right: string | null, +) => { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return compareDateOnly(right, left); +}; + const buildBillsSnapshot = ( rows: CurrentPeriodTransactionRow[], ): DashboardBillsSnapshot => { + const today = getBusinessDateString(); const bills = rows .filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO) .map((row) => ({ @@ -143,17 +186,44 @@ const buildBillsSnapshot = ( })) .sort((a, b) => { if (a.isSettled !== b.isSettled) { - return Number(a.isSettled) - Number(b.isSettled); + return a.isSettled ? 1 : -1; } - const dueA = a.dueDate - ? new Date(a.dueDate).getTime() - : Number.POSITIVE_INFINITY; - const dueB = b.dueDate - ? new Date(b.dueDate).getTime() - : Number.POSITIVE_INFINITY; - if (dueA !== dueB) { - return dueA - dueB; + if (!a.isSettled && !b.isSettled) { + const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false; + const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false; + + if (aIsOverdue !== bIsOverdue) { + return aIsOverdue ? -1 : 1; + } + + const dueDateDiff = compareDateOnlyAscWithNullsLast( + a.dueDate, + b.dueDate, + ); + if (dueDateDiff !== 0) { + return dueDateDiff; + } + + const amountDiff = b.amount - a.amount; + if (amountDiff !== 0) { + return amountDiff; + } + } + + if (a.isSettled && b.isSettled) { + const paidAtDiff = compareDateOnlyDescWithNullsLast( + a.boletoPaymentDate, + b.boletoPaymentDate, + ); + if (paidAtDiff !== 0) { + return paidAtDiff; + } + + const amountDiff = b.amount - a.amount; + if (amountDiff !== 0) { + return amountDiff; + } } return a.name.localeCompare(b.name, "pt-BR"); @@ -181,7 +251,7 @@ const buildPaymentStatusData = ( for (const row of rows) { if ( - !shouldIncludeWithoutAutoInvoice(row.note) || + !shouldIncludeInPaymentStatus(row) || (row.transactionType !== TRANSACTION_TYPE_INCOME && row.transactionType !== TRANSACTION_TYPE_EXPENSE) ) { @@ -496,6 +566,8 @@ export async function fetchDashboardCurrentPeriodOverview( categoryType: categories.type, cardLogo: cards.logo, accountLogo: financialAccounts.logo, + accountExcludeInitialBalanceFromIncome: + financialAccounts.excludeInitialBalanceFromIncome, }) .from(transactions) .leftJoin(cards, eq(transactions.cardId, cards.id)) @@ -509,6 +581,7 @@ export async function fetchDashboardCurrentPeriodOverview( eq(transactions.userId, userId), eq(transactions.period, period), eq(transactions.payerId, adminPayerId), + excludeTransactionsFromExcludedAccounts(), ), ) .orderBy( diff --git a/src/features/dashboard/dashboard-metrics-queries.ts b/src/features/dashboard/dashboard-metrics-queries.ts index bd509f2..b721e34 100644 --- a/src/features/dashboard/dashboard-metrics-queries.ts +++ b/src/features/dashboard/dashboard-metrics-queries.ts @@ -1,9 +1,10 @@ -import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm"; import { financialAccounts, transactions } from "@/db/schema"; import { buildDashboardAdminFilters, excludeAutoInvoiceEntries, excludeInitialBalanceWhenConfigured, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -36,12 +37,14 @@ export type DashboardCardMetrics = { type PeriodTotals = { receitas: number; despesas: number; + transferAdjustment: number; balanco: number; }; const createEmptyTotals = (): PeriodTotals => ({ receitas: 0, despesas: 0, + transferAdjustment: 0, balanco: 0, }); @@ -90,6 +93,7 @@ export async function fetchDashboardCardMetrics( period: transactions.period, transactionType: transactions.transactionType, totalAmount: sum(transactions.amount).as("total"), + accountExcludeFromBalance: financialAccounts.excludeFromBalance, }) .from(transactions) .leftJoin( @@ -101,12 +105,21 @@ export async function fetchDashboardCardMetrics( ...buildDashboardAdminFilters({ userId, adminPayerId }), gte(transactions.period, startPeriod), lte(transactions.period, period), - ne(transactions.transactionType, TRANSFERENCIA), + inArray(transactions.transactionType, [ + RECEITA, + DESPESA, + TRANSFERENCIA, + ]), excludeAutoInvoiceEntries(), excludeInitialBalanceWhenConfigured(), + excludeTransactionsFromExcludedAccounts(), ), ) - .groupBy(transactions.period, transactions.transactionType) + .groupBy( + transactions.period, + transactions.transactionType, + financialAccounts.excludeFromBalance, + ) .orderBy(asc(transactions.period), asc(transactions.transactionType)); const periodTotals = new Map(); @@ -119,6 +132,11 @@ export async function fetchDashboardCardMetrics( totals.receitas += total; } else if (row.transactionType === DESPESA) { totals.despesas += Math.abs(total); + } else if ( + row.transactionType === TRANSFERENCIA && + row.accountExcludeFromBalance === false + ) { + totals.transferAdjustment += total; } } @@ -139,7 +157,8 @@ export async function fetchDashboardCardMetrics( for (const key of periodRange) { const totals = ensurePeriodTotals(periodTotals, key); - totals.balanco = totals.receitas - totals.despesas; + totals.balanco = + totals.receitas - totals.despesas + totals.transferAdjustment; runningForecast += totals.balanco; forecastByPeriod.set(key, runningForecast); } diff --git a/src/features/dashboard/income-expense-balance-queries.ts b/src/features/dashboard/income-expense-balance-queries.ts index 529843e..8a24576 100644 --- a/src/features/dashboard/income-expense-balance-queries.ts +++ b/src/features/dashboard/income-expense-balance-queries.ts @@ -4,6 +4,7 @@ import { buildDashboardAdminFilters, excludeAutoInvoiceEntries, excludeInitialBalanceWhenConfigured, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -51,6 +52,7 @@ export async function fetchIncomeExpenseBalance( period: transactions.period, transactionType: transactions.transactionType, total: sql`coalesce(sum(${transactions.amount}), 0)`, + accountExcludeFromBalance: financialAccounts.excludeFromBalance, }) .from(transactions) .leftJoin( @@ -61,37 +63,62 @@ export async function fetchIncomeExpenseBalance( and( ...buildDashboardAdminFilters({ userId, adminPayerId }), inArray(transactions.period, periods), - inArray(transactions.transactionType, ["Receita", "Despesa"]), + inArray(transactions.transactionType, [ + "Receita", + "Despesa", + "Transferência", + ]), excludeAutoInvoiceEntries(), excludeInitialBalanceWhenConfigured(), + excludeTransactionsFromExcludedAccounts(), ), ) - .groupBy(transactions.period, transactions.transactionType); + .groupBy( + transactions.period, + transactions.transactionType, + financialAccounts.excludeFromBalance, + ); // Build lookup from query results - const dataMap = new Map(); + const dataMap = new Map< + string, + { income: number; expense: number; transferAdjustment: number } + >(); for (const row of rows) { if (!row.period) continue; - const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 }; - const total = Math.abs(toNumber(row.total)); + const entry = dataMap.get(row.period) ?? { + income: 0, + expense: 0, + transferAdjustment: 0, + }; + const total = toNumber(row.total); if (row.transactionType === "Receita") { - entry.income = total; + entry.income += Math.abs(total); } else if (row.transactionType === "Despesa") { - entry.expense = total; + entry.expense += Math.abs(total); + } else if ( + row.transactionType === "Transferência" && + row.accountExcludeFromBalance === false + ) { + entry.transferAdjustment += total; } dataMap.set(row.period, entry); } // Build result array preserving period order const months = periods.map((period) => { - const entry = dataMap.get(period) ?? { income: 0, expense: 0 }; + const entry = dataMap.get(period) ?? { + income: 0, + expense: 0, + transferAdjustment: 0, + }; return { month: period, monthLabel: formatPeriodMonthShort(period).toLowerCase(), income: entry.income, expense: entry.expense, - balance: entry.income - entry.expense, + balance: entry.income - entry.expense + entry.transferAdjustment, }; }); diff --git a/src/features/dashboard/invoices-helpers.ts b/src/features/dashboard/invoices-helpers.ts index e1dc641..317654d 100644 --- a/src/features/dashboard/invoices-helpers.ts +++ b/src/features/dashboard/invoices-helpers.ts @@ -7,7 +7,9 @@ import { import { getBusinessDateString } from "@/shared/utils/date"; import { buildDueDateInfoFromPeriodDay, + buildRelativeDueDateInfoFromPeriodDay, formatFinancialDateLabel, + formatRelativeFinancialDateLabel, } from "@/shared/utils/financial-dates"; import { formatPercentage } from "@/shared/utils/percentage"; import { formatPeriodForUrl } from "@/shared/utils/period"; @@ -45,6 +47,13 @@ export const parseInvoiceDueDate = ( return buildDueDateInfoFromPeriodDay(period, dueDay); }; +export const parseInvoiceWidgetDueDate = ( + period: string, + dueDay: string, +): InvoiceDueDateInfo => { + return buildRelativeDueDateInfoFromPeriodDay(period, dueDay); +}; + export const formatInvoicePaymentDate = ( value: string | null, ): InvoicePaymentDateInfo | null => { @@ -58,6 +67,19 @@ export const formatInvoicePaymentDate = ( }; }; +export const formatInvoiceWidgetPaymentDate = ( + value: string | null, +): InvoicePaymentDateInfo | null => { + const label = formatRelativeFinancialDateLabel(value, "paid"); + if (!label) { + return null; + } + + return { + label, + }; +}; + export const getCurrentDateString = () => getBusinessDateString(); const formatInvoiceSharePercentage = (value: number) => { diff --git a/src/features/dashboard/invoices-queries.ts b/src/features/dashboard/invoices-queries.ts index d3751a4..3a7f7e5 100644 --- a/src/features/dashboard/invoices-queries.ts +++ b/src/features/dashboard/invoices-queries.ts @@ -7,7 +7,13 @@ import { INVOICE_STATUS_VALUES, type InvoicePaymentStatus, } from "@/shared/lib/invoices"; -import { toDateOnlyString } from "@/shared/utils/date"; +import { + buildDateOnlyStringFromPeriodDay, + compareDateOnly, + getBusinessDateString, + isDateOnlyPast, + toDateOnlyString, +} from "@/shared/utils/date"; import { safeToNumber as toNumber } from "@/shared/utils/number"; type RawDashboardInvoice = { @@ -68,10 +74,31 @@ const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus => const buildFallbackId = (cardId: string, period: string) => `${cardId}:${period}`; +const compareDateOnlyAscWithNullsLast = ( + left: string | null, + right: string | null, +) => { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return compareDateOnly(left, right); +}; + +const compareDateOnlyDescWithNullsLast = ( + left: string | null, + right: string | null, +) => { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return compareDateOnly(right, left); +}; + export async function fetchDashboardInvoices( userId: string, period: string, ): Promise { + const today = getBusinessDateString(); const paymentRows = await db .select({ note: transactions.note, @@ -258,8 +285,53 @@ export async function fetchDashboardInvoices( } invoiceList.sort((a, b) => { - // Ordena do maior valor para o menor - return Math.abs(b.totalAmount) - Math.abs(a.totalAmount); + const aIsPending = a.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING; + const bIsPending = b.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING; + if (aIsPending !== bIsPending) { + return aIsPending ? -1 : 1; + } + + if (aIsPending && bIsPending) { + const aDueDate = buildDateOnlyStringFromPeriodDay(a.period, a.dueDay); + const bDueDate = buildDateOnlyStringFromPeriodDay(b.period, b.dueDay); + const aIsOverdue = aDueDate ? isDateOnlyPast(aDueDate, today) : false; + const bIsOverdue = bDueDate ? isDateOnlyPast(bDueDate, today) : false; + + if (aIsOverdue !== bIsOverdue) { + return aIsOverdue ? -1 : 1; + } + + const dueDateDiff = compareDateOnlyAscWithNullsLast(aDueDate, bDueDate); + if (dueDateDiff !== 0) { + return dueDateDiff; + } + + const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount); + if (amountDiff !== 0) { + return amountDiff; + } + } + + if (!aIsPending && !bIsPending) { + const paidAtDiff = compareDateOnlyDescWithNullsLast(a.paidAt, b.paidAt); + if (paidAtDiff !== 0) { + return paidAtDiff; + } + + const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount); + if (amountDiff !== 0) { + return amountDiff; + } + } + + const nameDiff = a.cardName.localeCompare(b.cardName, "pt-BR", { + sensitivity: "base", + }); + if (nameDiff !== 0) { + return nameDiff; + } + + return a.id.localeCompare(b.id); }); const totalPending = invoiceList.reduce((total, invoice) => { diff --git a/src/features/dashboard/payers-queries.ts b/src/features/dashboard/payers-queries.ts index 4005a4e..b2fe4f5 100644 --- a/src/features/dashboard/payers-queries.ts +++ b/src/features/dashboard/payers-queries.ts @@ -1,5 +1,6 @@ import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { payers, transactions } from "@/db/schema"; +import { financialAccounts, payers, transactions } from "@/db/schema"; +import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; import { db } from "@/shared/lib/db"; import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; @@ -41,11 +42,16 @@ export async function fetchDashboardPayers( }) .from(transactions) .innerJoin(payers, eq(transactions.payerId, payers.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( eq(transactions.userId, userId), inArray(transactions.period, [period, previousPeriod]), eq(transactions.transactionType, "Despesa"), + excludeTransactionsFromExcludedAccounts(), or( isNull(transactions.note), sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, diff --git a/src/features/dashboard/payments/payment-status-queries.ts b/src/features/dashboard/payments/payment-status-queries.ts index f7f110c..3efbaa7 100644 --- a/src/features/dashboard/payments/payment-status-queries.ts +++ b/src/features/dashboard/payments/payment-status-queries.ts @@ -1,8 +1,10 @@ -import { and, inArray, sql } from "drizzle-orm"; -import { transactions } from "@/db/schema"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { financialAccounts, transactions } from "@/db/schema"; import { buildDashboardAdminPeriodFilters, excludeAutoInvoiceEntries, + excludeInitialBalanceWhenConfigured, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -52,6 +54,10 @@ export async function fetchPaymentStatus( `, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( ...buildDashboardAdminPeriodFilters({ @@ -61,6 +67,8 @@ export async function fetchPaymentStatus( }), inArray(transactions.transactionType, ["Receita", "Despesa"]), excludeAutoInvoiceEntries(), + excludeInitialBalanceWhenConfigured(), + excludeTransactionsFromExcludedAccounts(), ), ) .groupBy(transactions.transactionType); diff --git a/src/features/dashboard/period-overview-queries.ts b/src/features/dashboard/period-overview-queries.ts index fe2359c..cb712e2 100644 --- a/src/features/dashboard/period-overview-queries.ts +++ b/src/features/dashboard/period-overview-queries.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, gte, inArray, lte, ne, sum } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm"; import { financialAccounts, transactions } from "@/db/schema"; import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; import type { @@ -9,6 +9,7 @@ import { buildDashboardAdminFilters, excludeAutoInvoiceEntries, excludeInitialBalanceWhenConfigured, + excludeTransactionsFromExcludedAccounts, } from "@/features/dashboard/transaction-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; @@ -30,6 +31,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência"; type PeriodTotals = { receitas: number; despesas: number; + transferAdjustment: number; balanco: number; }; @@ -37,6 +39,7 @@ type PeriodSummaryRow = { period: string | null; transactionType: string; totalAmount: string | number | null; + accountExcludeFromBalance: boolean | null; }; export type DashboardPeriodOverview = { @@ -47,6 +50,7 @@ export type DashboardPeriodOverview = { const createEmptyTotals = (): PeriodTotals => ({ receitas: 0, despesas: 0, + transferAdjustment: 0, balanco: 0, }); @@ -106,6 +110,7 @@ export async function fetchDashboardPeriodOverview( period: transactions.period, transactionType: transactions.transactionType, totalAmount: sum(transactions.amount).as("total"), + accountExcludeFromBalance: financialAccounts.excludeFromBalance, }) .from(transactions) .leftJoin( @@ -120,13 +125,18 @@ export async function fetchDashboardPeriodOverview( inArray(transactions.transactionType, [ TRANSACTION_TYPE_INCOME, TRANSACTION_TYPE_EXPENSE, + TRANSACTION_TYPE_TRANSFER, ]), - ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFER), excludeAutoInvoiceEntries(), excludeInitialBalanceWhenConfigured(), + excludeTransactionsFromExcludedAccounts(), ), ) - .groupBy(transactions.period, transactions.transactionType) + .groupBy( + transactions.period, + transactions.transactionType, + financialAccounts.excludeFromBalance, + ) .orderBy( asc(transactions.period), asc(transactions.transactionType), @@ -146,6 +156,11 @@ export async function fetchDashboardPeriodOverview( totals.receitas += total; } else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) { totals.despesas += Math.abs(total); + } else if ( + row.transactionType === TRANSACTION_TYPE_TRANSFER && + row.accountExcludeFromBalance === false + ) { + totals.transferAdjustment += total; } } @@ -164,7 +179,8 @@ export async function fetchDashboardPeriodOverview( for (const key of periodRange) { const totals = ensurePeriodTotals(periodTotals, key); - totals.balanco = totals.receitas - totals.despesas; + totals.balanco = + totals.receitas - totals.despesas + totals.transferAdjustment; runningForecast += totals.balanco; forecastByPeriod.set(key, runningForecast); } @@ -179,7 +195,7 @@ export async function fetchDashboardPeriodOverview( monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(), income: entry.receitas, expense: entry.despesas, - balance: entry.receitas - entry.despesas, + balance: entry.balanco, }; }); diff --git a/src/features/dashboard/transaction-filters.ts b/src/features/dashboard/transaction-filters.ts index 9e072c7..a4e85bb 100644 --- a/src/features/dashboard/transaction-filters.ts +++ b/src/features/dashboard/transaction-filters.ts @@ -5,6 +5,8 @@ import { INITIAL_BALANCE_NOTE, } from "@/shared/lib/accounts/constants"; +export { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters"; + type DashboardAdminFiltersParams = { userId: string; adminPayerId: string; diff --git a/src/features/dashboard/widgets/actions.ts b/src/features/dashboard/widgets/actions.ts index e89d244..90c0223 100644 --- a/src/features/dashboard/widgets/actions.ts +++ b/src/features/dashboard/widgets/actions.ts @@ -8,36 +8,44 @@ import { db, schema } from "@/shared/lib/db"; export type WidgetPreferences = { order: string[]; hidden: string[]; + myAccountsShowExcluded?: boolean; }; +type WidgetLayoutPreferences = Pick; + +async function upsertUserWidgetPreferences( + userId: string, + updates: Partial, +): Promise { + const existing = await db + .select({ dashboardWidgets: schema.userPreferences.dashboardWidgets }) + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, userId)) + .limit(1); + + const current = existing[0]?.dashboardWidgets; + const next: WidgetPreferences = { + order: current?.order ?? [], + hidden: current?.hidden ?? [], + myAccountsShowExcluded: current?.myAccountsShowExcluded, + ...updates, + }; + + await db + .insert(schema.userPreferences) + .values({ userId, dashboardWidgets: next }) + .onConflictDoUpdate({ + target: schema.userPreferences.userId, + set: { dashboardWidgets: next, updatedAt: new Date() }, + }); +} + export async function updateWidgetPreferences( - preferences: WidgetPreferences, + preferences: WidgetLayoutPreferences, ): Promise<{ success: boolean; error?: string }> { try { const user = await getUser(); - - // Check if preferences exist - const existing = await db - .select({ id: schema.userPreferences.id }) - .from(schema.userPreferences) - .where(eq(schema.userPreferences.userId, user.id)) - .limit(1); - - if (existing.length > 0) { - await db - .update(schema.userPreferences) - .set({ - dashboardWidgets: preferences, - updatedAt: new Date(), - }) - .where(eq(schema.userPreferences.userId, user.id)); - } else { - await db.insert(schema.userPreferences).values({ - userId: user.id, - dashboardWidgets: preferences, - }); - } - + await upsertUserWidgetPreferences(user.id, preferences); revalidatePath("/dashboard"); return { success: true }; } catch (error) { @@ -46,6 +54,24 @@ export async function updateWidgetPreferences( } } +export async function updateMyAccountsWidgetPreference({ + showExcludedAccounts, +}: { + showExcludedAccounts: boolean; +}): Promise<{ success: boolean; error?: string }> { + try { + const user = await getUser(); + await upsertUserWidgetPreferences(user.id, { + myAccountsShowExcluded: showExcludedAccounts, + }); + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + console.error("Error updating my accounts widget preference:", error); + return { success: false, error: "Erro ao salvar preferência do widget" }; + } +} + export async function resetWidgetPreferences(): Promise<{ success: boolean; error?: string; diff --git a/src/features/dashboard/widgets/widgets-config.tsx b/src/features/dashboard/widgets/widgets-config.tsx index 60c7200..5922cdb 100644 --- a/src/features/dashboard/widgets/widgets-config.tsx +++ b/src/features/dashboard/widgets/widgets-config.tsx @@ -31,6 +31,7 @@ import { PaymentStatusWidget } from "@/features/dashboard/components/payment-sta import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget"; import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget"; import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget"; +import type { WidgetPreferences } from "@/features/dashboard/widgets/actions"; import type { DashboardData } from "../fetch-dashboard-data"; export type WidgetConfig = { @@ -38,7 +39,12 @@ export type WidgetConfig = { title: string; subtitle: string; icon: ReactNode; - component: (props: { data: DashboardData; period: string }) => ReactNode; + component: (props: { + data: DashboardData; + period: string; + widgetPreferences: WidgetPreferences; + onMyAccountsShowExcludedChange?: (value: boolean) => void; + }) => ReactNode; action?: ReactNode; }; @@ -48,9 +54,16 @@ export const widgetsConfig: WidgetConfig[] = [ title: "Minhas Contas", subtitle: "Saldo consolidado disponível", icon: , - component: ({ data, period }) => ( + component: ({ + data, + period, + widgetPreferences, + onMyAccountsShowExcludedChange, + }) => ( diff --git a/src/features/insights/actions/aggregate.ts b/src/features/insights/actions/aggregate.ts index 195fe60..b3b3295 100644 --- a/src/features/insights/actions/aggregate.ts +++ b/src/features/insights/actions/aggregate.ts @@ -9,6 +9,7 @@ import { transactions, } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; +import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { safeToNumber } from "@/shared/utils/number"; @@ -36,12 +37,14 @@ async function aggregateMonthDataInternal(userId: string, period: string) { transactionType, excludeTransfers = true, excludeAutoInvoice = true, + excludeExcludedAccounts = true, }: { period?: string; periods?: string[]; transactionType?: string; excludeTransfers?: boolean; excludeAutoInvoice?: boolean; + excludeExcludedAccounts?: boolean; }) => { const conditions = [eq(transactions.userId, userId), adminPayerCondition]; @@ -60,6 +63,9 @@ async function aggregateMonthDataInternal(userId: string, period: string) { if (excludeAutoInvoice) { conditions.push(autoInvoiceExclusion); } + if (excludeExcludedAccounts) { + conditions.push(excludeTransactionsFromExcludedAccounts()); + } return conditions; }; @@ -84,6 +90,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { totalAmount: sql`coalesce(sum(${transactions.amount}), 0)`, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where(and(...buildAdminTransactionConditions({ period }))) .groupBy(transactions.transactionType), db @@ -92,6 +102,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { totalAmount: sql`coalesce(sum(${transactions.amount}), 0)`, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and(...buildAdminTransactionConditions({ period: previousPeriod })), ) @@ -102,6 +116,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { totalAmount: sql`coalesce(sum(${transactions.amount}), 0)`, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo }))) .groupBy(transactions.transactionType), db @@ -110,6 +128,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { totalAmount: sql`coalesce(sum(${transactions.amount}), 0)`, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and(...buildAdminTransactionConditions({ period: threeMonthsAgo })), ) @@ -121,6 +143,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { }) .from(transactions) .innerJoin(categories, eq(transactions.categoryId, categories.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( ...buildAdminTransactionConditions({ @@ -137,7 +163,7 @@ async function aggregateMonthDataInternal(userId: string, period: string) { .select({ categoryName: categories.name, budgetAmount: budgets.amount, - spent: sql`coalesce(sum(${transactions.amount}), 0)`, + spent: sql`coalesce(sum(case when ${excludeTransactionsFromExcludedAccounts()} then ${transactions.amount} else 0 end), 0)`, }) .from(budgets) .innerJoin(categories, eq(budgets.categoryId, categories.id)) @@ -152,6 +178,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { autoInvoiceExclusion, ), ) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where(and(eq(budgets.userId, userId), eq(budgets.period, period))) .groupBy(categories.name, budgets.amount), db @@ -180,6 +210,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { transactionCount: sql`count(*)`, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where(and(...buildAdminTransactionConditions({ period }))), db .select({ @@ -187,6 +221,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { amount: transactions.amount, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( ...buildAdminTransactionConditions({ @@ -201,6 +239,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { total: sql`coalesce(sum(abs(${transactions.amount})), 0)`, }) .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( ...buildAdminTransactionConditions({ @@ -222,6 +264,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) { }) .from(transactions) .leftJoin(categories, eq(transactions.categoryId, categories.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where( and( ...buildAdminTransactionConditions({ diff --git a/src/features/reports/category-chart-queries.ts b/src/features/reports/category-chart-queries.ts index 6dc7191..7bf4534 100644 --- a/src/features/reports/category-chart-queries.ts +++ b/src/features/reports/category-chart-queries.ts @@ -1,6 +1,7 @@ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { categories, transactions } from "@/db/schema"; +import { categories, financialAccounts, transactions } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; +import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { safeToNumber as toNumber } from "@/shared/utils/number"; @@ -49,6 +50,7 @@ export async function fetchCategoryChartData( isNull(transactions.note), sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, ), + excludeTransactionsFromExcludedAccounts(), ]; if (categoryIds && categoryIds.length > 0) { @@ -67,6 +69,10 @@ export async function fetchCategoryChartData( }) .from(transactions) .innerJoin(categories, eq(transactions.categoryId, categories.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where(and(...whereConditions)) .groupBy( categories.id, diff --git a/src/features/reports/category-report-queries.ts b/src/features/reports/category-report-queries.ts index 64c1eee..bccce63 100644 --- a/src/features/reports/category-report-queries.ts +++ b/src/features/reports/category-report-queries.ts @@ -1,6 +1,7 @@ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { categories, transactions } from "@/db/schema"; +import { categories, financialAccounts, transactions } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; +import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { safeToNumber as toNumber } from "@/shared/utils/number"; @@ -43,6 +44,7 @@ export async function fetchCategoryReport( isNull(transactions.note), sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, ), + excludeTransactionsFromExcludedAccounts(), ]; // Add optional category filter @@ -62,6 +64,10 @@ export async function fetchCategoryReport( }) .from(transactions) .innerJoin(categories, eq(transactions.categoryId, categories.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) .where(and(...whereConditions)) .groupBy( categories.id, diff --git a/src/features/reports/establishments/queries.ts b/src/features/reports/establishments/queries.ts index 157bc68..7e49f15 100644 --- a/src/features/reports/establishments/queries.ts +++ b/src/features/reports/establishments/queries.ts @@ -19,6 +19,7 @@ import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/shared/lib/accounts/constants"; +import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { safeToNumber } from "@/shared/utils/number"; @@ -118,6 +119,7 @@ export async function fetchTopEstablishmentsData( isNull(financialAccounts.excludeInitialBalanceFromIncome), eq(financialAccounts.excludeInitialBalanceFromIncome, false), ), + excludeTransactionsFromExcludedAccounts(), ] as const; // Fetch establishments with transaction count and total amount diff --git a/src/shared/components/skeletons/dashboard-metrics-cards-skeleton.tsx b/src/shared/components/skeletons/dashboard-metrics-cards-skeleton.tsx index 5a744f4..ae63628 100644 --- a/src/shared/components/skeletons/dashboard-metrics-cards-skeleton.tsx +++ b/src/shared/components/skeletons/dashboard-metrics-cards-skeleton.tsx @@ -1,36 +1,48 @@ +import { RiInformationLine } from "@remixicon/react"; import { Card, - CardFooter, + CardContent, + CardDescription, CardHeader, CardTitle, } from "@/shared/components/ui/card"; +import { Separator } from "@/shared/components/ui/separator"; import { Skeleton } from "@/shared/components/ui/skeleton"; export function DashboardMetricsCardsSkeleton() { return (
    {Array.from({ length: 4 }).map((_, index) => ( - - - - - - -
    + + +
    +
    + + + + + + + + +
    +
    + +
    + + +
    - - - -
    +
    + -
    - + ))}
    diff --git a/src/shared/lib/accounts/query-filters.ts b/src/shared/lib/accounts/query-filters.ts new file mode 100644 index 0000000..f9d415d --- /dev/null +++ b/src/shared/lib/accounts/query-filters.ts @@ -0,0 +1,9 @@ +import { eq, isNull, or, sql } from "drizzle-orm"; +import { financialAccounts, transactions } from "@/db/schema"; + +export const excludeTransactionsFromExcludedAccounts = () => + or( + isNull(transactions.accountId), + isNull(financialAccounts.excludeFromBalance), + eq(financialAccounts.excludeFromBalance, false), + ) ?? sql`true`; diff --git a/src/shared/utils/financial-dates.ts b/src/shared/utils/financial-dates.ts index 42e80dd..0eaed7c 100644 --- a/src/shared/utils/financial-dates.ts +++ b/src/shared/utils/financial-dates.ts @@ -1,6 +1,9 @@ import { buildDateOnlyStringFromPeriodDay, formatDateOnlyLabel, + getBusinessDateString, + parseUtcDateString, + toDateOnlyString, } from "@/shared/utils/date"; type FinancialStatusLabelInput = { @@ -16,6 +19,8 @@ type FinancialDueDateInfo = { date: string | null; }; +type RelativeFinancialDateContext = "due" | "paid"; + export function formatFinancialDateLabel( value: string | null, prefix?: string, @@ -24,6 +29,63 @@ export function formatFinancialDateLabel( return formatDateOnlyLabel(value, prefix, options); } +function getOffsetDateString( + referenceDate: string, + offset: number, +): string | null { + const parsedReference = parseUtcDateString(referenceDate); + if (!parsedReference) { + return null; + } + + parsedReference.setUTCDate(parsedReference.getUTCDate() + offset); + return toDateOnlyString(parsedReference); +} + +export function formatRelativeFinancialDateLabel( + value: string | null, + context: RelativeFinancialDateContext, + options?: { + referenceDate?: string | Date | null; + }, +): string | null { + const normalizedValue = toDateOnlyString(value); + if (!normalizedValue) { + return null; + } + + const referenceDate = + toDateOnlyString(options?.referenceDate) ?? getBusinessDateString(); + const yesterday = getOffsetDateString(referenceDate, -1); + const tomorrow = getOffsetDateString(referenceDate, 1); + + if (context === "due") { + if (normalizedValue === referenceDate) { + return "Vence hoje"; + } + + if (normalizedValue === tomorrow) { + return "Vence amanhã"; + } + + if (normalizedValue === yesterday) { + return "Venceu ontem"; + } + + return formatFinancialDateLabel(normalizedValue, "Vence em"); + } + + if (normalizedValue === referenceDate) { + return "Pago hoje"; + } + + if (normalizedValue === yesterday) { + return "Pago ontem"; + } + + return formatFinancialDateLabel(normalizedValue, "Pago em"); +} + export function buildFinancialStatusLabel({ isSettled, dueDate, @@ -38,6 +100,18 @@ export function buildFinancialStatusLabel({ return formatFinancialDateLabel(dueDate, duePrefix); } +export function buildRelativeFinancialStatusLabel({ + isSettled, + dueDate, + paidAt, +}: FinancialStatusLabelInput): string | null { + if (isSettled) { + return formatRelativeFinancialDateLabel(paidAt, "paid"); + } + + return formatRelativeFinancialDateLabel(dueDate, "due"); +} + export function buildDueDateInfoFromPeriodDay( period: string, dueDay: string, @@ -64,3 +138,28 @@ export function buildDueDateInfoFromPeriodDay( date: dueDate, }; } + +export function buildRelativeDueDateInfoFromPeriodDay( + period: string, + dueDay: string, + options?: { + fallbackPrefix?: string; + }, +): FinancialDueDateInfo { + const fallbackPrefix = options?.fallbackPrefix ?? "Vence dia"; + const dueDate = buildDateOnlyStringFromPeriodDay(period, dueDay); + + if (!dueDate) { + return { + label: `${fallbackPrefix} ${dueDay}`, + date: null, + }; + } + + return { + label: + formatRelativeFinancialDateLabel(dueDate, "due") ?? + `${fallbackPrefix} ${dueDay}`, + date: dueDate, + }; +}