mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
fix(financeiro): alinhar saldo, métricas e relatórios
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}%`}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
src/shared/lib/accounts/query-filters.ts
Normal file
9
src/shared/lib/accounts/query-filters.ts
Normal 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`;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user