fix(financeiro): alinhar saldo, métricas e relatórios

This commit is contained in:
Felipe Coutinho
2026-04-03 18:10:43 +00:00
parent acaf9d5c27
commit 549a5bdba1
32 changed files with 960 additions and 118 deletions

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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<DashboardBillsSnapshot> {
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;

View File

@@ -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(

View File

@@ -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(

View File

@@ -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"),

View File

@@ -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 (
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
@@ -29,6 +40,23 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? (
statusTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"cursor-help rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{statusTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"rounded-full py-0.5",
@@ -37,6 +65,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
>
{statusLabel}
</span>
)
) : null}
</div>
</div>

View File

@@ -56,7 +56,7 @@ export function BillPaymentDialog({
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
@@ -93,7 +93,7 @@ export function BillPaymentDialog({
{bill ? (
<div className="space-y-3">
{/* Card principal */}
<div className="rounded-xl border bg-muted/30 p-4">
<div className="rounded-xl border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto
</p>

View File

@@ -76,6 +76,9 @@ export function DashboardGridEditable({
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
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,
})}
</ExpandableWidgetCard>
</div>
</SortableWidget>

View File

@@ -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 (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{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) {
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1 tracking-tight">
<CardTitle className="flex items-center gap-1.5 tracking-tight">
<Icon className={cn("size-4", iconClass)} aria-hidden />
{label}
<MetricsCardInfoButton
label={label}
helpTitle={helpTitle}
helpLines={helpLines}
/>
</CardTitle>
<CardDescription className="mt-1.5 tracking-tight">
{subtitle}

View File

@@ -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 = (
<Link
@@ -113,9 +128,33 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{!isPaid ? (
dueTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help">{dueInfo.label}</span>
</TooltipTrigger>
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
</Tooltip>
) : (
<span>{dueInfo.label}</span>
)
) : null}
{isPaid && paymentInfo ? (
paymentTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help text-success">
{paymentInfo.label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{paymentTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span className="text-success">{paymentInfo.label}</span>
)
) : null}
</div>
</div>

View File

@@ -63,7 +63,7 @@ export function InvoicePaymentDialog({
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
@@ -100,7 +100,7 @@ export function InvoicePaymentDialog({
{invoice ? (
<div className="space-y-3">
{/* Card principal */}
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-4">
<div className="flex items-center gap-3 rounded-xl border p-4">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}

View File

@@ -0,0 +1,44 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/shared/components/ui/hover-card";
type MetricsCardInfoButtonProps = {
label: string;
helpTitle: string;
helpLines: readonly string[];
};
export function MetricsCardInfoButton({
label,
helpTitle,
helpLines,
}: MetricsCardInfoButtonProps) {
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
aria-label={`Entenda como ${label.toLowerCase()} é calculado`}
>
<RiInformationLine className="size-4" aria-hidden />
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-80 space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{helpTitle}</p>
</div>
<ul className="space-y-2 text-xs text-muted-foreground">
{helpLines.map((line) => (
<li key={`${label}-${line}`}>{line}</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -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 (
<>
<div className="flex justify-between py-1">
Saldo Total
<div className="flex items-start justify-between gap-3 py-1">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo Total</p>
<MoneyValues className="text-2xl" amount={totalBalance} />
</div>
{excludedAccountsCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={isPending}
className="mt-0.5 text-muted-foreground"
aria-label={toggleButtonLabel}
onClick={handleToggleExcludedAccounts}
>
{showExcludedAccounts ? (
<RiEyeOffLine className="size-4" aria-hidden />
) : (
<RiEyeLine className="size-4" aria-hidden />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">{toggleButtonLabel}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
{hiddenExcludedAccountsCount > 0 ? (
<p className="pb-2 text-xs text-muted-foreground">
{hiddenExcludedAccountsCount}{" "}
{hiddenExcludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
<div>
{displayedAccounts.length === 0 ? (
{accounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
@@ -43,6 +127,14 @@ export function MyAccountsWidget({
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/>
</div>
) : displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={<RiEyeOffLine className="size-6 text-muted-foreground" />}
title="As contas não consideradas estão ocultas"
description="Use o botão no topo do widget para mostrá-las novamente."
/>
</div>
) : (
<ul className="flex flex-col">
{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
/>
</Link>
{account.excludeFromBalance ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help ml-2">
<Badge className="font-normal" variant="info">
Não considerada
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">
Esta conta aparece na lista, mas não entra no
cálculo do saldo total porque está marcada para
desconsiderar do saldo total.
</p>
</TooltipContent>
</Tooltip>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div>
@@ -95,7 +208,7 @@ export function MyAccountsWidget({
)}
</div>
{visibleAccounts.length > displayedAccounts.length ? (
{remainingCount > 0 ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas
</CardFooter>

View File

@@ -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(

View File

@@ -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<string, PeriodTotals>();
@@ -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);
}

View File

@@ -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<number>`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<string, { income: number; expense: number }>();
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,
};
});

View File

@@ -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) => {

View File

@@ -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<DashboardInvoicesSnapshot> {
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) => {

View File

@@ -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}%`}`,

View File

@@ -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);

View File

@@ -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,
};
});

View File

@@ -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;

View File

@@ -8,36 +8,44 @@ import { db, schema } from "@/shared/lib/db";
export type WidgetPreferences = {
order: string[];
hidden: string[];
myAccountsShowExcluded?: boolean;
};
export async function updateWidgetPreferences(
preferences: WidgetPreferences,
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getUser();
type WidgetLayoutPreferences = Pick<WidgetPreferences, "order" | "hidden">;
// Check if preferences exist
async function upsertUserWidgetPreferences(
userId: string,
updates: Partial<WidgetPreferences>,
): Promise<void> {
const existing = await db
.select({ id: schema.userPreferences.id })
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
if (existing.length > 0) {
const current = existing[0]?.dashboardWidgets;
const next: WidgetPreferences = {
order: current?.order ?? [],
hidden: current?.hidden ?? [],
myAccountsShowExcluded: current?.myAccountsShowExcluded,
...updates,
};
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,
.insert(schema.userPreferences)
.values({ userId, dashboardWidgets: next })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { dashboardWidgets: next, updatedAt: new Date() },
});
}
export async function updateWidgetPreferences(
preferences: WidgetLayoutPreferences,
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getUser();
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;

View File

@@ -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: <RiBarChartBoxLine className="size-4" />,
component: ({ data, period }) => (
component: ({
data,
period,
widgetPreferences,
onMyAccountsShowExcludedChange,
}) => (
<MyAccountsWidget
accounts={data.accountsSnapshot.accounts}
showExcludedAccounts={widgetPreferences.myAccountsShowExcluded ?? true}
onShowExcludedAccountsChange={onMyAccountsShowExcludedChange}
totalBalance={data.accountsSnapshot.totalBalance}
period={period}
/>

View File

@@ -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<number>`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<number>`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<number>`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<number>`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<number>`coalesce(sum(${transactions.amount}), 0)`,
spent: sql<number>`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<number>`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<number>`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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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 (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card
key={index}
className="@container/card min-h-36 justify-between gap-0"
>
<CardHeader className="gap-4 pb-3">
<CardTitle className="flex items-center gap-2">
<Skeleton className="size-8 rounded-md bg-foreground/10" />
<Card key={index} className="gap-2 overflow-hidden">
<CardHeader>
<div className="flex items-start justify-between">
<div className="w-full">
<CardTitle className="flex items-center gap-1.5 tracking-tight">
<Skeleton className="size-4 rounded-sm bg-foreground/10" />
<Skeleton className="h-4 w-24 rounded-md bg-foreground/10" />
<RiInformationLine
className="size-4 text-muted-foreground/40"
aria-hidden
/>
</CardTitle>
<div className="flex flex-wrap items-end justify-between gap-3">
<CardDescription className="mt-1.5 tracking-tight">
<Skeleton className="h-3 w-32 rounded-md bg-foreground/10" />
</CardDescription>
</div>
</div>
<Separator className="mt-1" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="mt-1 flex flex-wrap items-center justify-between gap-2">
<Skeleton className="h-10 w-36 rounded-md bg-foreground/10" />
<Skeleton className="h-7 w-20 rounded-full bg-foreground/10" />
</div>
</CardHeader>
<CardFooter className="items-start pt-0">
<div className="flex flex-col items-start gap-1.5">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-28 rounded-md bg-foreground/10" />
<Skeleton className="h-3 w-24 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
</div>
</CardFooter>
</CardContent>
</Card>
))}
</div>

View File

@@ -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`;

View File

@@ -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,
};
}