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<{
|
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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}%`}`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 {
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user