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<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];
myAccountsShowExcluded?: boolean;
}>(), }>(),
createdAt: timestamp("created_at", { createdAt: timestamp("created_at", {
mode: "date", 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 { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
import { import {
buildFinancialStatusLabel, buildFinancialStatusLabel,
buildRelativeFinancialStatusLabel,
formatFinancialDateLabel, formatFinancialDateLabel,
} from "@/shared/utils/financial-dates"; } 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 getCurrentBillDateString = () => getBusinessDateString();
export const isBillOverdue = (bill: DashboardBill) => { export const isBillOverdue = (bill: DashboardBill) => {

View File

@@ -1,10 +1,15 @@
"use server"; "use server";
import { and, asc, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { transactions } from "@/db/schema";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; 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"; import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = {
pendingCount: number; 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( export async function fetchDashboardBills(
userId: string, userId: string,
period: string, period: string,
): Promise<DashboardBillsSnapshot> { ): Promise<DashboardBillsSnapshot> {
const today = getBusinessDateString();
const adminPayerId = await getAdminPayerId(userId); const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) { if (!adminPayerId) {
return { bills: [], totalPendingAmount: 0, pendingCount: 0 }; return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
@@ -59,11 +85,6 @@ export async function fetchDashboardBills(
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO), eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(transactions.payerId, adminPayerId), eq(transactions.payerId, adminPayerId),
), ),
)
.orderBy(
asc(transactions.isSettled),
asc(transactions.dueDate),
asc(transactions.name),
); );
const bills = rows.map((row: RawDashboardBill): DashboardBill => { 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 totalPendingAmount = 0;
let pendingCount = 0; let pendingCount = 0;

View File

@@ -1,5 +1,10 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { budgets, categories, transactions } from "@/db/schema"; import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import { import {
buildCategoryBreakdownData, buildCategoryBreakdownData,
type DashboardCategoryBreakdownData, type DashboardCategoryBreakdownData,
@@ -8,6 +13,7 @@ import {
import { import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
}) })
.from(transactions) .from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id)) .innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildDashboardAdminFilters({ userId, adminPayerId }), ...buildDashboardAdminFilters({ userId, adminPayerId }),
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
eq(transactions.transactionType, "Despesa"), eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"), eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy( .groupBy(

View File

@@ -14,6 +14,7 @@ import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
eq(categories.type, "receita"), eq(categories.type, "receita"),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(), excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy( .groupBy(

View File

@@ -20,6 +20,7 @@ import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
and( and(
...buildDashboardAdminFilters({ userId, adminPayerId }), ...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]), inArray(transactions.period, [period, previousPeriod]),
excludeTransactionsFromExcludedAccounts(),
or( or(
and( and(
eq(transactions.transactionType, "Despesa"), eq(transactions.transactionType, "Despesa"),

View File

@@ -1,12 +1,18 @@
import { RiCheckboxCircleFill } from "@remixicon/react"; import { RiCheckboxCircleFill } from "@remixicon/react";
import { import {
buildBillStatusLabel, buildBillStatusLabel,
buildBillWidgetStatusLabel,
isBillOverdue, isBillOverdue,
} from "@/features/dashboard/bills-helpers"; } from "@/features/dashboard/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type BillListItemProps = { type BillListItemProps = {
@@ -15,8 +21,13 @@ type BillListItemProps = {
}; };
export function BillListItem({ bill, onPay }: BillListItemProps) { export function BillListItem({ bill, onPay }: BillListItemProps) {
const statusLabel = buildBillStatusLabel(bill); const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill); const overdue = isBillOverdue(bill);
const statusTooltipLabel =
statusLabel && statusLabel !== absoluteStatusLabel
? absoluteStatusLabel
: null;
return ( return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5"> <li className="flex items-center justify-between transition-all duration-300 py-1.5">
@@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</span> </span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? ( {statusLabel ? (
<span statusTooltipLabel ? (
className={cn( <Tooltip>
"rounded-full py-0.5", <TooltipTrigger asChild>
bill.isSettled && "text-success", <span
)} className={cn(
> "cursor-help rounded-full py-0.5",
{statusLabel} bill.isSettled && "text-success",
</span> )}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{statusTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
)
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -56,7 +56,7 @@ export function BillPaymentDialog({
}} }}
> >
<DialogContent <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) => { onEscapeKeyDown={(event) => {
if (isProcessing) { if (isProcessing) {
event.preventDefault(); event.preventDefault();
@@ -93,7 +93,7 @@ export function BillPaymentDialog({
{bill ? ( {bill ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Card principal */} {/* 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"> <p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto Boleto
</p> </p>

View File

@@ -76,6 +76,9 @@ export function DashboardGridEditable({
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>( const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [], initialPreferences?.hidden ?? [],
); );
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
initialPreferences?.myAccountsShowExcluded ?? true,
);
// Keep track of original state for cancel // Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder); const [originalOrder, setOriginalOrder] = useState(widgetOrder);
@@ -186,6 +189,7 @@ export function DashboardGridEditable({
if (result.success) { if (result.success) {
setWidgetOrder(DEFAULT_WIDGET_ORDER); setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]); setHiddenWidgets([]);
setMyAccountsShowExcluded(true);
toast.success("Preferências restauradas!"); toast.success("Preferências restauradas!");
} else { } else {
toast.error(result.error ?? "Erro ao restaurar"); toast.error(result.error ?? "Erro ao restaurar");
@@ -361,7 +365,16 @@ export function DashboardGridEditable({
icon={widget.icon} icon={widget.icon}
action={widget.action} action={widget.action}
> >
{widget.component({ data, period })} {widget.component({
data,
period,
widgetPreferences: {
order: widgetOrder,
hidden: hiddenWidgets,
myAccountsShowExcluded,
},
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})}
</ExpandableWidgetCard> </ExpandableWidgetCard>
</div> </div>
</SortableWidget> </SortableWidget>

View File

@@ -7,6 +7,7 @@ import {
RiScalesLine, RiScalesLine,
RiSubtractLine, RiSubtractLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { import {
@@ -36,6 +37,14 @@ const CARDS = [
icon: RiArrowDownLine, icon: RiArrowDownLine,
invertTrend: false, invertTrend: false,
iconClass: "text-success", 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", label: "Despesas",
@@ -44,14 +53,29 @@ const CARDS = [
icon: RiArrowUpLine, icon: RiArrowUpLine,
invertTrend: true, invertTrend: true,
iconClass: "text-destructive", 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", label: "Balanço",
subtitle: "Receitas menos despesas", subtitle: "Receitas, despesas e ajustes entre contas",
key: "balanco", key: "balanco",
icon: RiScalesLine, icon: RiScalesLine,
invertTrend: false, invertTrend: false,
iconClass: "text-warning", 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", label: "Previsto",
@@ -60,6 +84,13 @@ const CARDS = [
icon: RiCalendarCheckLine, icon: RiCalendarCheckLine,
invertTrend: false, invertTrend: false,
iconClass: "text-cyan-600", 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; ] as const;
@@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map( {CARDS.map(
({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => { ({
label,
subtitle,
key,
icon: Icon,
invertTrend,
iconClass,
helpTitle,
helpLines,
}) => {
const metric = metrics[key]; const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous); const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend]; const TrendIcon = TREND_ICONS[trend];
@@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <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 /> <Icon className={cn("size-4", iconClass)} aria-hidden />
{label} {label}
<MetricsCardInfoButton
label={label}
helpTitle={helpTitle}
helpLines={helpLines}
/>
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5 tracking-tight"> <CardDescription className="mt-1.5 tracking-tight">
{subtitle} {subtitle}

View File

@@ -4,8 +4,10 @@ import {
buildInvoiceDetailsHref, buildInvoiceDetailsHref,
buildInvoiceInitials, buildInvoiceInitials,
formatInvoicePaymentDate, formatInvoicePaymentDate,
formatInvoiceWidgetPaymentDate,
getInvoiceShareLabel, getInvoiceShareLabel,
parseInvoiceDueDate, parseInvoiceDueDate,
parseInvoiceWidgetDueDate,
} from "@/features/dashboard/invoices-helpers"; } from "@/features/dashboard/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries"; import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -20,6 +22,11 @@ import {
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
} from "@/shared/components/ui/hover-card"; } from "@/shared/components/ui/hover-card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { isDateOnlyPast } from "@/shared/utils/date"; import { isDateOnlyPast } from "@/shared/utils/date";
@@ -31,14 +38,22 @@ type InvoiceListItemProps = {
}; };
export function InvoiceListItem({ invoice, onPay }: 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 isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue = const isOverdue =
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date); !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 breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0; const hasBreakdown = breakdown.length > 0;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period); 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 = ( const linkNode = (
<Link <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"> <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 ? ( {isPaid && paymentInfo ? (
<span className="text-success">{paymentInfo.label}</span> 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} ) : null}
</div> </div>
</div> </div>

View File

@@ -63,7 +63,7 @@ export function InvoicePaymentDialog({
}} }}
> >
<DialogContent <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) => { onEscapeKeyDown={(event) => {
if (isProcessing) { if (isProcessing) {
event.preventDefault(); event.preventDefault();
@@ -100,7 +100,7 @@ export function InvoicePaymentDialog({
{invoice ? ( {invoice ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Card principal */} {/* 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 <InvoiceLogo
cardName={invoice.cardName} cardName={invoice.cardName}
logo={invoice.logo} 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 Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useTransition } from "react";
import { toast } from "sonner";
import type { DashboardAccount } from "@/features/dashboard/accounts-queries"; import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widgets/actions";
import MoneyValues from "@/shared/components/money-values"; 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 { CardFooter } from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatPeriodForUrl } from "@/shared/utils/period"; import { formatPeriodForUrl } from "@/shared/utils/period";
type MyAccountsWidgetProps = { type MyAccountsWidgetProps = {
accounts: DashboardAccount[]; accounts: DashboardAccount[];
showExcludedAccounts: boolean;
onShowExcludedAccountsChange?: (value: boolean) => void;
totalBalance: number; totalBalance: number;
period: string; period: string;
}; };
export function MyAccountsWidget({ export function MyAccountsWidget({
accounts, accounts,
showExcludedAccounts,
onShowExcludedAccountsChange,
totalBalance, totalBalance,
period, period,
}: MyAccountsWidgetProps) { }: MyAccountsWidgetProps) {
const visibleAccounts = accounts.filter( const [isPending, startTransition] = useTransition();
(account) => !account.excludeFromBalance,
); const excludedAccountsCount = accounts.filter(
(account) => account.excludeFromBalance,
).length;
const visibleAccounts = showExcludedAccounts
? accounts
: accounts.filter((account) => !account.excludeFromBalance);
const displayedAccounts = visibleAccounts.slice(0, 5); const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length; 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 ( return (
<> <>
<div className="flex justify-between py-1"> <div className="flex items-start justify-between gap-3 py-1">
Saldo Total <div className="space-y-1">
<MoneyValues className="text-2xl" amount={totalBalance} /> <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> </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> <div>
{displayedAccounts.length === 0 ? ( {accounts.length === 0 ? (
<div className="-mt-10"> <div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={ icon={
@@ -43,6 +127,14 @@ export function MyAccountsWidget({
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações." description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/> />
</div> </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"> <ul className="flex flex-col">
{displayedAccounts.map((account) => { {displayedAccounts.map((account) => {
@@ -60,6 +152,7 @@ export function MyAccountsWidget({
src={logoSrc} src={logoSrc}
alt={`Logo da conta ${account.name}`} alt={`Logo da conta ${account.name}`}
fill fill
sizes="38px"
className="object-contain rounded-full" className="object-contain rounded-full"
/> />
) : null} ) : null}
@@ -79,6 +172,26 @@ export function MyAccountsWidget({
aria-hidden aria-hidden
/> />
</Link> </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"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span> <span className="truncate">{account.accountType}</span>
</div> </div>
@@ -95,7 +208,7 @@ export function MyAccountsWidget({
)} )}
</div> </div>
{visibleAccounts.length > displayedAccounts.length ? ( {remainingCount > 0 ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground"> <CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas +{remainingCount} contas não exibidas
</CardFooter> </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 { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries"; import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries"; import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
import { import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants"; } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; 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"; import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -54,6 +60,7 @@ type CurrentPeriodTransactionRow = {
categoryType: string | null; categoryType: string | null;
cardLogo: string | null; cardLogo: string | null;
accountLogo: string | null; accountLogo: string | null;
accountExcludeInitialBalanceFromIncome: boolean | null;
}; };
type CategoryOption = PurchasesByCategoryData["categories"][number]; type CategoryOption = PurchasesByCategoryData["categories"][number];
@@ -112,6 +119,21 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) => const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note); !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 shouldIncludeNamedItem = (name: string) => {
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
@@ -126,9 +148,30 @@ const shouldIncludeNamedItem = (name: string) => {
return true; 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 = ( const buildBillsSnapshot = (
rows: CurrentPeriodTransactionRow[], rows: CurrentPeriodTransactionRow[],
): DashboardBillsSnapshot => { ): DashboardBillsSnapshot => {
const today = getBusinessDateString();
const bills = rows const bills = rows
.filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO) .filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO)
.map((row) => ({ .map((row) => ({
@@ -143,17 +186,44 @@ const buildBillsSnapshot = (
})) }))
.sort((a, b) => { .sort((a, b) => {
if (a.isSettled !== b.isSettled) { if (a.isSettled !== b.isSettled) {
return Number(a.isSettled) - Number(b.isSettled); return a.isSettled ? 1 : -1;
} }
const dueA = a.dueDate if (!a.isSettled && !b.isSettled) {
? new Date(a.dueDate).getTime() const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
: Number.POSITIVE_INFINITY; const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
const dueB = b.dueDate
? new Date(b.dueDate).getTime() if (aIsOverdue !== bIsOverdue) {
: Number.POSITIVE_INFINITY; return aIsOverdue ? -1 : 1;
if (dueA !== dueB) { }
return dueA - dueB;
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"); return a.name.localeCompare(b.name, "pt-BR");
@@ -181,7 +251,7 @@ const buildPaymentStatusData = (
for (const row of rows) { for (const row of rows) {
if ( if (
!shouldIncludeWithoutAutoInvoice(row.note) || !shouldIncludeInPaymentStatus(row) ||
(row.transactionType !== TRANSACTION_TYPE_INCOME && (row.transactionType !== TRANSACTION_TYPE_INCOME &&
row.transactionType !== TRANSACTION_TYPE_EXPENSE) row.transactionType !== TRANSACTION_TYPE_EXPENSE)
) { ) {
@@ -496,6 +566,8 @@ export async function fetchDashboardCurrentPeriodOverview(
categoryType: categories.type, categoryType: categories.type,
cardLogo: cards.logo, cardLogo: cards.logo,
accountLogo: financialAccounts.logo, accountLogo: financialAccounts.logo,
accountExcludeInitialBalanceFromIncome:
financialAccounts.excludeInitialBalanceFromIncome,
}) })
.from(transactions) .from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id)) .leftJoin(cards, eq(transactions.cardId, cards.id))
@@ -509,6 +581,7 @@ export async function fetchDashboardCurrentPeriodOverview(
eq(transactions.userId, userId), eq(transactions.userId, userId),
eq(transactions.period, period), eq(transactions.period, period),
eq(transactions.payerId, adminPayerId), eq(transactions.payerId, adminPayerId),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.orderBy( .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 { financialAccounts, transactions } from "@/db/schema";
import { import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -36,12 +37,14 @@ export type DashboardCardMetrics = {
type PeriodTotals = { type PeriodTotals = {
receitas: number; receitas: number;
despesas: number; despesas: number;
transferAdjustment: number;
balanco: number; balanco: number;
}; };
const createEmptyTotals = (): PeriodTotals => ({ const createEmptyTotals = (): PeriodTotals => ({
receitas: 0, receitas: 0,
despesas: 0, despesas: 0,
transferAdjustment: 0,
balanco: 0, balanco: 0,
}); });
@@ -90,6 +93,7 @@ export async function fetchDashboardCardMetrics(
period: transactions.period, period: transactions.period,
transactionType: transactions.transactionType, transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"), totalAmount: sum(transactions.amount).as("total"),
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
}) })
.from(transactions) .from(transactions)
.leftJoin( .leftJoin(
@@ -101,12 +105,21 @@ export async function fetchDashboardCardMetrics(
...buildDashboardAdminFilters({ userId, adminPayerId }), ...buildDashboardAdminFilters({ userId, adminPayerId }),
gte(transactions.period, startPeriod), gte(transactions.period, startPeriod),
lte(transactions.period, period), lte(transactions.period, period),
ne(transactions.transactionType, TRANSFERENCIA), inArray(transactions.transactionType, [
RECEITA,
DESPESA,
TRANSFERENCIA,
]),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(), excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy(transactions.period, transactions.transactionType) .groupBy(
transactions.period,
transactions.transactionType,
financialAccounts.excludeFromBalance,
)
.orderBy(asc(transactions.period), asc(transactions.transactionType)); .orderBy(asc(transactions.period), asc(transactions.transactionType));
const periodTotals = new Map<string, PeriodTotals>(); const periodTotals = new Map<string, PeriodTotals>();
@@ -119,6 +132,11 @@ export async function fetchDashboardCardMetrics(
totals.receitas += total; totals.receitas += total;
} else if (row.transactionType === DESPESA) { } else if (row.transactionType === DESPESA) {
totals.despesas += Math.abs(total); 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) { for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key); const totals = ensurePeriodTotals(periodTotals, key);
totals.balanco = totals.receitas - totals.despesas; totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment;
runningForecast += totals.balanco; runningForecast += totals.balanco;
forecastByPeriod.set(key, runningForecast); forecastByPeriod.set(key, runningForecast);
} }

View File

@@ -4,6 +4,7 @@ import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -51,6 +52,7 @@ export async function fetchIncomeExpenseBalance(
period: transactions.period, period: transactions.period,
transactionType: transactions.transactionType, transactionType: transactions.transactionType,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`, total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
}) })
.from(transactions) .from(transactions)
.leftJoin( .leftJoin(
@@ -61,37 +63,62 @@ export async function fetchIncomeExpenseBalance(
and( and(
...buildDashboardAdminFilters({ userId, adminPayerId }), ...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, periods), inArray(transactions.period, periods),
inArray(transactions.transactionType, ["Receita", "Despesa"]), inArray(transactions.transactionType, [
"Receita",
"Despesa",
"Transferência",
]),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(), excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy(transactions.period, transactions.transactionType); .groupBy(
transactions.period,
transactions.transactionType,
financialAccounts.excludeFromBalance,
);
// Build lookup from query results // 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) { for (const row of rows) {
if (!row.period) continue; if (!row.period) continue;
const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 }; const entry = dataMap.get(row.period) ?? {
const total = Math.abs(toNumber(row.total)); income: 0,
expense: 0,
transferAdjustment: 0,
};
const total = toNumber(row.total);
if (row.transactionType === "Receita") { if (row.transactionType === "Receita") {
entry.income = total; entry.income += Math.abs(total);
} else if (row.transactionType === "Despesa") { } 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); dataMap.set(row.period, entry);
} }
// Build result array preserving period order // Build result array preserving period order
const months = periods.map((period) => { 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 { return {
month: period, month: period,
monthLabel: formatPeriodMonthShort(period).toLowerCase(), monthLabel: formatPeriodMonthShort(period).toLowerCase(),
income: entry.income, income: entry.income,
expense: entry.expense, 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 { getBusinessDateString } from "@/shared/utils/date";
import { import {
buildDueDateInfoFromPeriodDay, buildDueDateInfoFromPeriodDay,
buildRelativeDueDateInfoFromPeriodDay,
formatFinancialDateLabel, formatFinancialDateLabel,
formatRelativeFinancialDateLabel,
} from "@/shared/utils/financial-dates"; } from "@/shared/utils/financial-dates";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period"; import { formatPeriodForUrl } from "@/shared/utils/period";
@@ -45,6 +47,13 @@ export const parseInvoiceDueDate = (
return buildDueDateInfoFromPeriodDay(period, dueDay); return buildDueDateInfoFromPeriodDay(period, dueDay);
}; };
export const parseInvoiceWidgetDueDate = (
period: string,
dueDay: string,
): InvoiceDueDateInfo => {
return buildRelativeDueDateInfoFromPeriodDay(period, dueDay);
};
export const formatInvoicePaymentDate = ( export const formatInvoicePaymentDate = (
value: string | null, value: string | null,
): InvoicePaymentDateInfo | 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(); export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => { const formatInvoiceSharePercentage = (value: number) => {

View File

@@ -7,7 +7,13 @@ import {
INVOICE_STATUS_VALUES, INVOICE_STATUS_VALUES,
type InvoicePaymentStatus, type InvoicePaymentStatus,
} from "@/shared/lib/invoices"; } 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"; import { safeToNumber as toNumber } from "@/shared/utils/number";
type RawDashboardInvoice = { type RawDashboardInvoice = {
@@ -68,10 +74,31 @@ const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
const buildFallbackId = (cardId: string, period: string) => const buildFallbackId = (cardId: string, period: string) =>
`${cardId}:${period}`; `${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( export async function fetchDashboardInvoices(
userId: string, userId: string,
period: string, period: string,
): Promise<DashboardInvoicesSnapshot> { ): Promise<DashboardInvoicesSnapshot> {
const today = getBusinessDateString();
const paymentRows = await db const paymentRows = await db
.select({ .select({
note: transactions.note, note: transactions.note,
@@ -258,8 +285,53 @@ export async function fetchDashboardInvoices(
} }
invoiceList.sort((a, b) => { invoiceList.sort((a, b) => {
// Ordena do maior valor para o menor const aIsPending = a.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount); 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) => { const totalPending = invoiceList.reduce((total, invoice) => {

View File

@@ -1,5 +1,6 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; 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 { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
@@ -41,11 +42,16 @@ export async function fetchDashboardPayers(
}) })
.from(transactions) .from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id)) .innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
eq(transactions.userId, userId), eq(transactions.userId, userId),
inArray(transactions.period, [period, previousPeriod]), inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Despesa"), eq(transactions.transactionType, "Despesa"),
excludeTransactionsFromExcludedAccounts(),
or( or(
isNull(transactions.note), isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,

View File

@@ -1,8 +1,10 @@
import { and, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { financialAccounts, transactions } from "@/db/schema";
import { import {
buildDashboardAdminPeriodFilters, buildDashboardAdminPeriodFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -52,6 +54,10 @@ export async function fetchPaymentStatus(
`, `,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildDashboardAdminPeriodFilters({ ...buildDashboardAdminPeriodFilters({
@@ -61,6 +67,8 @@ export async function fetchPaymentStatus(
}), }),
inArray(transactions.transactionType, ["Receita", "Despesa"]), inArray(transactions.transactionType, ["Receita", "Despesa"]),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy(transactions.transactionType); .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 { financialAccounts, transactions } from "@/db/schema";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import type { import type {
@@ -9,6 +9,7 @@ import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -30,6 +31,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência";
type PeriodTotals = { type PeriodTotals = {
receitas: number; receitas: number;
despesas: number; despesas: number;
transferAdjustment: number;
balanco: number; balanco: number;
}; };
@@ -37,6 +39,7 @@ type PeriodSummaryRow = {
period: string | null; period: string | null;
transactionType: string; transactionType: string;
totalAmount: string | number | null; totalAmount: string | number | null;
accountExcludeFromBalance: boolean | null;
}; };
export type DashboardPeriodOverview = { export type DashboardPeriodOverview = {
@@ -47,6 +50,7 @@ export type DashboardPeriodOverview = {
const createEmptyTotals = (): PeriodTotals => ({ const createEmptyTotals = (): PeriodTotals => ({
receitas: 0, receitas: 0,
despesas: 0, despesas: 0,
transferAdjustment: 0,
balanco: 0, balanco: 0,
}); });
@@ -106,6 +110,7 @@ export async function fetchDashboardPeriodOverview(
period: transactions.period, period: transactions.period,
transactionType: transactions.transactionType, transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"), totalAmount: sum(transactions.amount).as("total"),
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
}) })
.from(transactions) .from(transactions)
.leftJoin( .leftJoin(
@@ -120,13 +125,18 @@ export async function fetchDashboardPeriodOverview(
inArray(transactions.transactionType, [ inArray(transactions.transactionType, [
TRANSACTION_TYPE_INCOME, TRANSACTION_TYPE_INCOME,
TRANSACTION_TYPE_EXPENSE, TRANSACTION_TYPE_EXPENSE,
TRANSACTION_TYPE_TRANSFER,
]), ]),
ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFER),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(), excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy(transactions.period, transactions.transactionType) .groupBy(
transactions.period,
transactions.transactionType,
financialAccounts.excludeFromBalance,
)
.orderBy( .orderBy(
asc(transactions.period), asc(transactions.period),
asc(transactions.transactionType), asc(transactions.transactionType),
@@ -146,6 +156,11 @@ export async function fetchDashboardPeriodOverview(
totals.receitas += total; totals.receitas += total;
} else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) { } else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) {
totals.despesas += Math.abs(total); 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) { for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key); const totals = ensurePeriodTotals(periodTotals, key);
totals.balanco = totals.receitas - totals.despesas; totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment;
runningForecast += totals.balanco; runningForecast += totals.balanco;
forecastByPeriod.set(key, runningForecast); forecastByPeriod.set(key, runningForecast);
} }
@@ -179,7 +195,7 @@ export async function fetchDashboardPeriodOverview(
monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(), monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(),
income: entry.receitas, income: entry.receitas,
expense: entry.despesas, expense: entry.despesas,
balance: entry.receitas - entry.despesas, balance: entry.balanco,
}; };
}); });

View File

@@ -5,6 +5,8 @@ import {
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants"; } from "@/shared/lib/accounts/constants";
export { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
type DashboardAdminFiltersParams = { type DashboardAdminFiltersParams = {
userId: string; userId: string;
adminPayerId: string; adminPayerId: string;

View File

@@ -8,36 +8,44 @@ import { db, schema } from "@/shared/lib/db";
export type WidgetPreferences = { export type WidgetPreferences = {
order: string[]; order: string[];
hidden: string[]; hidden: string[];
myAccountsShowExcluded?: boolean;
}; };
type WidgetLayoutPreferences = Pick<WidgetPreferences, "order" | "hidden">;
async function upsertUserWidgetPreferences(
userId: string,
updates: Partial<WidgetPreferences>,
): Promise<void> {
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( export async function updateWidgetPreferences(
preferences: WidgetPreferences, preferences: WidgetLayoutPreferences,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const user = await getUser(); const user = await getUser();
await upsertUserWidgetPreferences(user.id, preferences);
// 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,
});
}
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error) { } 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<{ export async function resetWidgetPreferences(): Promise<{
success: boolean; success: boolean;
error?: string; 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 { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget"; import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-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"; import type { DashboardData } from "../fetch-dashboard-data";
export type WidgetConfig = { export type WidgetConfig = {
@@ -38,7 +39,12 @@ export type WidgetConfig = {
title: string; title: string;
subtitle: string; subtitle: string;
icon: ReactNode; icon: ReactNode;
component: (props: { data: DashboardData; period: string }) => ReactNode; component: (props: {
data: DashboardData;
period: string;
widgetPreferences: WidgetPreferences;
onMyAccountsShowExcludedChange?: (value: boolean) => void;
}) => ReactNode;
action?: ReactNode; action?: ReactNode;
}; };
@@ -48,9 +54,16 @@ export const widgetsConfig: WidgetConfig[] = [
title: "Minhas Contas", title: "Minhas Contas",
subtitle: "Saldo consolidado disponível", subtitle: "Saldo consolidado disponível",
icon: <RiBarChartBoxLine className="size-4" />, icon: <RiBarChartBoxLine className="size-4" />,
component: ({ data, period }) => ( component: ({
data,
period,
widgetPreferences,
onMyAccountsShowExcludedChange,
}) => (
<MyAccountsWidget <MyAccountsWidget
accounts={data.accountsSnapshot.accounts} accounts={data.accountsSnapshot.accounts}
showExcludedAccounts={widgetPreferences.myAccountsShowExcluded ?? true}
onShowExcludedAccountsChange={onMyAccountsShowExcludedChange}
totalBalance={data.accountsSnapshot.totalBalance} totalBalance={data.accountsSnapshot.totalBalance}
period={period} period={period}
/> />

View File

@@ -9,6 +9,7 @@ import {
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; 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 { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number"; import { safeToNumber } from "@/shared/utils/number";
@@ -36,12 +37,14 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
transactionType, transactionType,
excludeTransfers = true, excludeTransfers = true,
excludeAutoInvoice = true, excludeAutoInvoice = true,
excludeExcludedAccounts = true,
}: { }: {
period?: string; period?: string;
periods?: string[]; periods?: string[];
transactionType?: string; transactionType?: string;
excludeTransfers?: boolean; excludeTransfers?: boolean;
excludeAutoInvoice?: boolean; excludeAutoInvoice?: boolean;
excludeExcludedAccounts?: boolean;
}) => { }) => {
const conditions = [eq(transactions.userId, userId), adminPayerCondition]; const conditions = [eq(transactions.userId, userId), adminPayerCondition];
@@ -60,6 +63,9 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
if (excludeAutoInvoice) { if (excludeAutoInvoice) {
conditions.push(autoInvoiceExclusion); conditions.push(autoInvoiceExclusion);
} }
if (excludeExcludedAccounts) {
conditions.push(excludeTransactionsFromExcludedAccounts());
}
return conditions; return conditions;
}; };
@@ -84,6 +90,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`, totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...buildAdminTransactionConditions({ period }))) .where(and(...buildAdminTransactionConditions({ period })))
.groupBy(transactions.transactionType), .groupBy(transactions.transactionType),
db db
@@ -92,6 +102,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`, totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and(...buildAdminTransactionConditions({ period: previousPeriod })), and(...buildAdminTransactionConditions({ period: previousPeriod })),
) )
@@ -102,6 +116,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`, totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo }))) .where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo })))
.groupBy(transactions.transactionType), .groupBy(transactions.transactionType),
db db
@@ -110,6 +128,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`, totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and(...buildAdminTransactionConditions({ period: threeMonthsAgo })), and(...buildAdminTransactionConditions({ period: threeMonthsAgo })),
) )
@@ -121,6 +143,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
}) })
.from(transactions) .from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id)) .innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildAdminTransactionConditions({ ...buildAdminTransactionConditions({
@@ -137,7 +163,7 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
.select({ .select({
categoryName: categories.name, categoryName: categories.name,
budgetAmount: budgets.amount, 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) .from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id)) .innerJoin(categories, eq(budgets.categoryId, categories.id))
@@ -152,6 +178,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
autoInvoiceExclusion, autoInvoiceExclusion,
), ),
) )
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))) .where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
.groupBy(categories.name, budgets.amount), .groupBy(categories.name, budgets.amount),
db db
@@ -180,6 +210,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
transactionCount: sql<number>`count(*)`, transactionCount: sql<number>`count(*)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...buildAdminTransactionConditions({ period }))), .where(and(...buildAdminTransactionConditions({ period }))),
db db
.select({ .select({
@@ -187,6 +221,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
amount: transactions.amount, amount: transactions.amount,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildAdminTransactionConditions({ ...buildAdminTransactionConditions({
@@ -201,6 +239,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`, total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildAdminTransactionConditions({ ...buildAdminTransactionConditions({
@@ -222,6 +264,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
}) })
.from(transactions) .from(transactions)
.leftJoin(categories, eq(transactions.categoryId, categories.id)) .leftJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildAdminTransactionConditions({ ...buildAdminTransactionConditions({

View File

@@ -1,6 +1,7 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; 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 { 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 { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -49,6 +50,7 @@ export async function fetchCategoryChartData(
isNull(transactions.note), isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
), ),
excludeTransactionsFromExcludedAccounts(),
]; ];
if (categoryIds && categoryIds.length > 0) { if (categoryIds && categoryIds.length > 0) {
@@ -67,6 +69,10 @@ export async function fetchCategoryChartData(
}) })
.from(transactions) .from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id)) .innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...whereConditions)) .where(and(...whereConditions))
.groupBy( .groupBy(
categories.id, categories.id,

View File

@@ -1,6 +1,7 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; 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 { 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 { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -43,6 +44,7 @@ export async function fetchCategoryReport(
isNull(transactions.note), isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
), ),
excludeTransactionsFromExcludedAccounts(),
]; ];
// Add optional category filter // Add optional category filter
@@ -62,6 +64,10 @@ export async function fetchCategoryReport(
}) })
.from(transactions) .from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id)) .innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...whereConditions)) .where(and(...whereConditions))
.groupBy( .groupBy(
categories.id, categories.id,

View File

@@ -19,6 +19,7 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants"; } from "@/shared/lib/accounts/constants";
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number"; import { safeToNumber } from "@/shared/utils/number";
@@ -118,6 +119,7 @@ export async function fetchTopEstablishmentsData(
isNull(financialAccounts.excludeInitialBalanceFromIncome), isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false), eq(financialAccounts.excludeInitialBalanceFromIncome, false),
), ),
excludeTransactionsFromExcludedAccounts(),
] as const; ] as const;
// Fetch establishments with transaction count and total amount // Fetch establishments with transaction count and total amount

View File

@@ -1,36 +1,48 @@
import { RiInformationLine } from "@remixicon/react";
import { import {
Card, Card,
CardFooter, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { Skeleton } from "@/shared/components/ui/skeleton"; import { Skeleton } from "@/shared/components/ui/skeleton";
export function DashboardMetricsCardsSkeleton() { export function DashboardMetricsCardsSkeleton() {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<Card <Card key={index} className="gap-2 overflow-hidden">
key={index} <CardHeader>
className="@container/card min-h-36 justify-between gap-0" <div className="flex items-start justify-between">
> <div className="w-full">
<CardHeader className="gap-4 pb-3"> <CardTitle className="flex items-center gap-1.5 tracking-tight">
<CardTitle className="flex items-center gap-2"> <Skeleton className="size-4 rounded-sm bg-foreground/10" />
<Skeleton className="size-8 rounded-md bg-foreground/10" /> <Skeleton className="h-4 w-24 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-24 rounded-md bg-foreground/10" /> <RiInformationLine
</CardTitle> className="size-4 text-muted-foreground/40"
<div className="flex flex-wrap items-end justify-between gap-3"> aria-hidden
/>
</CardTitle>
<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-10 w-36 rounded-md bg-foreground/10" />
<Skeleton className="h-7 w-20 rounded-full bg-foreground/10" /> <Skeleton className="h-7 w-20 rounded-full bg-foreground/10" />
</div> </div>
</CardHeader> <div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-28 rounded-md bg-foreground/10" />
<CardFooter className="items-start pt-0">
<div className="flex flex-col items-start gap-1.5">
<Skeleton className="h-3 w-24 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> </div>
</CardFooter> </CardContent>
</Card> </Card>
))} ))}
</div> </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 { import {
buildDateOnlyStringFromPeriodDay, buildDateOnlyStringFromPeriodDay,
formatDateOnlyLabel, formatDateOnlyLabel,
getBusinessDateString,
parseUtcDateString,
toDateOnlyString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
type FinancialStatusLabelInput = { type FinancialStatusLabelInput = {
@@ -16,6 +19,8 @@ type FinancialDueDateInfo = {
date: string | null; date: string | null;
}; };
type RelativeFinancialDateContext = "due" | "paid";
export function formatFinancialDateLabel( export function formatFinancialDateLabel(
value: string | null, value: string | null,
prefix?: string, prefix?: string,
@@ -24,6 +29,63 @@ export function formatFinancialDateLabel(
return formatDateOnlyLabel(value, prefix, options); 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({ export function buildFinancialStatusLabel({
isSettled, isSettled,
dueDate, dueDate,
@@ -38,6 +100,18 @@ export function buildFinancialStatusLabel({
return formatFinancialDateLabel(dueDate, duePrefix); 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( export function buildDueDateInfoFromPeriodDay(
period: string, period: string,
dueDay: string, dueDay: string,
@@ -64,3 +138,28 @@ export function buildDueDateInfoFromPeriodDay(
date: dueDate, 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,
};
}