refactor: agrega queries e cache do dashboard

This commit is contained in:
Felipe Coutinho
2026-03-20 18:38:20 +00:00
parent 5b8d25d894
commit 41fd8226cb
24 changed files with 1648 additions and 690 deletions

View File

@@ -64,9 +64,7 @@ export async function fetchDashboardAccounts(
eq(transactions.accountId, financialAccounts.id),
eq(transactions.userId, userId),
eq(transactions.isSettled, true),
adminPayerId
? eq(transactions.payerId, adminPayerId)
: sql`false`,
adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`,
),
)
.where(eq(financialAccounts.userId, userId))

View File

@@ -1,10 +1,5 @@
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
import {
categories,
financialAccounts,
payers,
transactions,
} from "@/db/schema";
import { categories, financialAccounts, transactions } from "@/db/schema";
import { mapTransactionsData } from "@/features/transactions/page-helpers";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
@@ -12,7 +7,7 @@ import {
} from "@/shared/lib/accounts/constants";
import type { CategoryType } from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { calculatePercentageChange } from "@/shared/utils/math";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
@@ -49,34 +44,37 @@ export async function fetchCategoryDetails(
const previousPeriod = getPreviousPeriod(period);
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
const adminPayerId = await getAdminPayerId(userId);
const sanitizedNote = or(
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
);
const currentRows = await db.query.transactions.findMany({
where: and(
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
sanitizedNote,
),
with: {
payer: true,
financialAccount: true,
card: true,
category: true,
},
orderBy: [desc(transactions.purchaseDate), desc(transactions.createdAt)],
});
const currentRows = adminPayerId
? await db.query.transactions.findMany({
where: and(
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
eq(transactions.payerId, adminPayerId),
sanitizedNote,
),
with: {
payer: true,
financialAccount: true,
card: true,
category: true,
},
orderBy: [
desc(transactions.purchaseDate),
desc(transactions.createdAt),
],
})
: [];
const filteredRows = currentRows.filter((row) => {
// Filtrar apenas payers admin
if (row.payer?.role !== PAYER_ROLE_ADMIN) return false;
// Excluir saldos iniciais se a conta tiver o flag ativo
if (
row.note === INITIAL_BALANCE_NOTE &&
row.financialAccount?.excludeInitialBalanceFromIncome
@@ -94,32 +92,32 @@ export async function fetchCategoryDetails(
0,
);
const [previousTotalRow] = await db
.select({
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(payers.role, PAYER_ROLE_ADMIN),
sanitizedNote,
eq(transactions.period, previousPeriod),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
);
const [previousTotalRow] = adminPayerId
? await db
.select({
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(transactions.payerId, adminPayerId),
sanitizedNote,
eq(transactions.period, previousPeriod),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
)
: [{ total: 0 }];
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
const percentageChange = calculatePercentageChange(

View File

@@ -0,0 +1,287 @@
import { and, eq, inArray, or, sql } from "drizzle-orm";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
} from "@/features/dashboard/categories/category-breakdown";
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
import type {
GoalProgressCategory,
GoalProgressItem,
GoalsProgressData,
} from "@/features/dashboard/goals-progress-queries";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
const BUDGET_CRITICAL_THRESHOLD = 80;
type CategorySnapshotRow = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
categoryType: string | null;
period: string | null;
condition: string;
total: number;
absoluteTotal: number;
};
type BudgetSnapshotRow = {
budgetId: string;
categoryId: string | null;
categoryName: string;
categoryIcon: string | null;
period: string;
createdAt: Date;
amount: string | number | null;
};
export type DashboardCategoryOverview = {
goalsProgressData: GoalsProgressData;
incomeByCategoryData: IncomeByCategoryData;
expensesByCategoryData: ExpensesByCategoryData;
};
const resolveStatus = (usedPercentage: number): GoalProgressItem["status"] => {
if (usedPercentage >= 100) {
return "exceeded";
}
if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) {
return "critical";
}
return "on-track";
};
const emptyOverview = (): DashboardCategoryOverview => ({
goalsProgressData: {
items: [],
categories: [],
totalBudgets: 0,
exceededCount: 0,
criticalCount: 0,
},
incomeByCategoryData: {
categories: [],
currentTotal: 0,
previousTotal: 0,
},
expensesByCategoryData: {
categories: [],
currentTotal: 0,
previousTotal: 0,
},
});
const aggregateCategoryRows = (
rows: CategorySnapshotRow[],
categoryType: "receita" | "despesa",
) => {
const grouped = new Map<
string,
{
categoryId: string;
categoryName: string;
categoryIcon: string | null;
period: string | null;
total: number;
}
>();
for (const row of rows) {
if (row.categoryType !== categoryType) {
continue;
}
const key = `${row.categoryId}:${row.period ?? "sem-periodo"}`;
const current = grouped.get(key) ?? {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
period: row.period,
total: 0,
};
current.total += toNumber(row.total);
grouped.set(key, current);
}
return Array.from(grouped.values());
};
export async function fetchDashboardCategoryOverview(
userId: string,
period: string,
): Promise<DashboardCategoryOverview> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return emptyOverview();
}
const previousPeriod = getPreviousPeriod(period);
const [transactionRows, budgetRows, categoryRows] = await Promise.all([
db
.select({
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
categoryType: categories.type,
period: transactions.period,
condition: transactions.condition,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
absoluteTotal: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
or(
and(
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
),
and(
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
),
),
)
.groupBy(
categories.id,
categories.name,
categories.icon,
categories.type,
transactions.period,
transactions.condition,
),
db
.select({
budgetId: budgets.id,
categoryId: budgets.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
period: budgets.period,
createdAt: budgets.createdAt,
amount: budgets.amount,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
db.query.categories.findMany({
where: and(eq(categories.userId, userId), eq(categories.type, "despesa")),
orderBy: (category, { asc }) => [asc(category.name)],
}),
]);
const snapshotRows = transactionRows as CategorySnapshotRow[];
const incomeRows = aggregateCategoryRows(snapshotRows, "receita");
const expenseRows = aggregateCategoryRows(snapshotRows, "despesa");
const budgetAmountRows = (budgetRows as BudgetSnapshotRow[]).map((row) => ({
categoryId: row.categoryId,
amount: row.amount,
}));
const incomeByCategoryData: DashboardCategoryBreakdownData =
buildCategoryBreakdownData({
rows: incomeRows,
budgetRows: budgetAmountRows,
period,
});
const expensesByCategoryData: DashboardCategoryBreakdownData =
buildCategoryBreakdownData({
rows: expenseRows,
budgetRows: budgetAmountRows,
period,
});
const currentExpenseMap = new Map<string, number>();
for (const row of snapshotRows) {
if (
row.categoryType === "despesa" &&
row.period === period &&
row.condition !== "cancelado"
) {
currentExpenseMap.set(
row.categoryId,
(currentExpenseMap.get(row.categoryId) ?? 0) +
toNumber(row.absoluteTotal),
);
}
}
const goalsCategories: GoalProgressCategory[] = categoryRows.map(
(category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}),
);
const goalItems: GoalProgressItem[] = (budgetRows as BudgetSnapshotRow[])
.map((row) => {
const budgetAmount = toNumber(row.amount);
const spentAmount = row.categoryId
? (currentExpenseMap.get(row.categoryId) ?? 0)
: 0;
const usedPercentage =
budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0;
return {
id: row.budgetId,
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
period: row.period,
createdAt: row.createdAt.toISOString(),
budgetAmount,
spentAmount,
usedPercentage,
status: resolveStatus(usedPercentage),
};
})
.sort((a, b) => b.usedPercentage - a.usedPercentage);
const exceededCount = goalItems.filter(
(item) => item.status === "exceeded",
).length;
const criticalCount = goalItems.filter(
(item) => item.status === "critical",
).length;
return {
goalsProgressData: {
items: goalItems,
categories: goalsCategories,
totalBudgets: goalItems.length,
exceededCount,
criticalCount,
},
incomeByCategoryData,
expensesByCategoryData,
};
}

View File

@@ -95,7 +95,7 @@ const getPercentChange = (current: number, previous: number): string => {
};
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
if (trend === "flat") return "bg-muted text-muted-foreground";
if (trend === "flat") return "text-muted-foreground";
const isPositive = invertTrend ? trend === "down" : trend === "up";
return isPositive ? "text-success" : "text-destructive";
};
@@ -120,10 +120,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1 tracking-tight">
<Icon
className={cn("size-4", iconClass)}
aria-hidden
/>
<Icon className={cn("size-4", iconClass)} aria-hidden />
{label}
</CardTitle>
<CardDescription className="mt-1.5 tracking-tight">

View File

@@ -38,7 +38,7 @@ export function InstallmentAnalysisPage({
return allInstallmentsSelected && data.installmentGroups.length > 0;
}, [selectedInstallments, data]);
// Função para selecionar/desselecionar tudo
// Função para selecionar/desmarcar tudo
const toggleSelectAll = () => {
if (isAllSelected) {
// Desmarcar tudo
@@ -59,7 +59,7 @@ export function InstallmentAnalysisPage({
}
};
// Função para selecionar/desselecionar um grupo de parcelas
// Função para selecionar/desmarcar um grupo de parcelas
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
const newMap = new Map(selectedInstallments);
const current = newMap.get(seriesId) || new Set<string>();
@@ -75,7 +75,7 @@ export function InstallmentAnalysisPage({
setSelectedInstallments(newMap);
};
// Função para selecionar/desselecionar parcela individual
// Função para selecionar/desmarcar parcela individual
const toggleInstallmentSelection = (
seriesId: string,
installmentId: string,

View File

@@ -69,7 +69,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
<HoverCardContent align="start" className="w-72 space-y-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<p className="text-xs text-muted-foreground">
Distribuição por pagador
</p>
<ul className="space-y-2">

View File

@@ -0,0 +1,531 @@
import { and, desc, eq } from "drizzle-orm";
import {
cards,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import type { DashboardBillsSnapshot } from "@/features/dashboard/bills-queries";
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
import type {
TopExpense,
TopExpensesData,
} from "@/features/dashboard/expenses/top-expenses-queries";
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
const PAYMENT_METHOD_CARD = "Cartão de Crédito";
const TRANSACTION_TYPE_EXPENSE = "Despesa";
const TRANSACTION_TYPE_INCOME = "Receita";
const CONDITION_RECURRING = "Recorrente";
const CONDITION_INSTALLMENT = "Parcelado";
type CurrentPeriodTransactionRow = {
id: string;
name: string;
amount: string | number | null;
period: string;
purchaseDate: Date;
paymentMethod: string;
condition: string;
currentInstallment: number | null;
installmentCount: number | null;
recurrenceCount: number | null;
dueDate: Date | null;
boletoPaymentDate: Date | null;
isSettled: boolean | null;
transactionType: string;
note: string | null;
isAnticipated: boolean;
categoryId: string | null;
categoryName: string | null;
categoryType: string | null;
cardLogo: string | null;
accountLogo: string | null;
};
type CategoryOption = PurchasesByCategoryData["categories"][number];
type CategoryTransaction =
PurchasesByCategoryData["transactionsByCategory"][string][number];
export type DashboardCurrentPeriodOverview = {
billsSnapshot: DashboardBillsSnapshot;
paymentStatusData: PaymentStatusData;
paymentConditionsData: PaymentConditionsData;
paymentMethodsData: PaymentMethodsData;
recurringExpensesData: RecurringExpensesData;
installmentExpensesData: InstallmentExpensesData;
topEstablishmentsData: TopEstablishmentsData;
topExpensesAll: TopExpensesData;
topExpensesCardOnly: TopExpensesData;
purchasesByCategoryData: PurchasesByCategoryData;
};
const emptyOverview = (): DashboardCurrentPeriodOverview => ({
billsSnapshot: {
bills: [],
totalPendingAmount: 0,
pendingCount: 0,
},
paymentStatusData: {
income: { total: 0, confirmed: 0, pending: 0 },
expenses: { total: 0, confirmed: 0, pending: 0 },
},
paymentConditionsData: { conditions: [] },
paymentMethodsData: { methods: [] },
recurringExpensesData: { expenses: [] },
installmentExpensesData: { expenses: [] },
topEstablishmentsData: { establishments: [] },
topExpensesAll: { expenses: [] },
topExpensesCardOnly: { expenses: [] },
purchasesByCategoryData: {
categories: [],
transactionsByCategory: {},
},
});
const normalizeNote = (note: string | null | undefined) => note?.trim() ?? "";
const isAutoInvoiceNote = (note: string | null | undefined) =>
normalizeNote(note)
.toLowerCase()
.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX.toLowerCase());
const isInitialBalanceNote = (note: string | null | undefined) =>
normalizeNote(note) === INITIAL_BALANCE_NOTE;
const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
!isAutoInvoiceNote(note);
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note);
const shouldIncludeNamedItem = (name: string) => {
const normalized = name.trim().toLowerCase();
if (normalized === "saldo inicial") {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
return true;
};
const buildBillsSnapshot = (
rows: CurrentPeriodTransactionRow[],
): DashboardBillsSnapshot => {
const bills = rows
.filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO)
.map((row) => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
dueDate: row.dueDate ? row.dueDate.toISOString().slice(0, 10) : null,
boletoPaymentDate: row.boletoPaymentDate
? row.boletoPaymentDate.toISOString().slice(0, 10)
: null,
isSettled: Boolean(row.isSettled),
}))
.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
return Number(a.isSettled) - Number(b.isSettled);
}
const dueA = a.dueDate
? new Date(a.dueDate).getTime()
: Number.POSITIVE_INFINITY;
const dueB = b.dueDate
? new Date(b.dueDate).getTime()
: Number.POSITIVE_INFINITY;
if (dueA !== dueB) {
return dueA - dueB;
}
return a.name.localeCompare(b.name, "pt-BR");
});
const pendingBills = bills.filter((bill) => !bill.isSettled);
return {
bills,
totalPendingAmount: pendingBills.reduce(
(total, bill) => total + bill.amount,
0,
),
pendingCount: pendingBills.length,
};
};
const buildPaymentStatusData = (
rows: CurrentPeriodTransactionRow[],
): PaymentStatusData => {
const result: PaymentStatusData = {
income: { total: 0, confirmed: 0, pending: 0 },
expenses: { total: 0, confirmed: 0, pending: 0 },
};
for (const row of rows) {
if (
!shouldIncludeWithoutAutoInvoice(row.note) ||
(row.transactionType !== TRANSACTION_TYPE_INCOME &&
row.transactionType !== TRANSACTION_TYPE_EXPENSE)
) {
continue;
}
const amount = toNumber(row.amount);
const target =
row.transactionType === TRANSACTION_TYPE_INCOME
? result.income
: result.expenses;
if (row.isSettled === true) {
target.confirmed += amount;
} else {
target.pending += amount;
}
}
result.income.total = result.income.confirmed + result.income.pending;
result.expenses.total = result.expenses.confirmed + result.expenses.pending;
return result;
};
const buildExpenseBreakdown = (
rows: CurrentPeriodTransactionRow[],
field: "condition" | "paymentMethod",
) => {
const groups = new Map<
string,
{
amount: number;
transactions: number;
}
>();
for (const row of rows) {
if (
row.transactionType !== TRANSACTION_TYPE_EXPENSE ||
!shouldIncludeWithoutAutoGenerated(row.note)
) {
continue;
}
const key = row[field];
const current = groups.get(key) ?? { amount: 0, transactions: 0 };
current.amount += Math.abs(toNumber(row.amount));
current.transactions += 1;
groups.set(key, current);
}
const entries = Array.from(groups.entries()).map(([key, value]) => ({
key,
amount: value.amount,
transactions: value.transactions,
}));
const overallTotal = entries.reduce(
(total, entry) => total + entry.amount,
0,
);
return entries
.map((entry) => ({
key: entry.key,
amount: entry.amount,
transactions: entry.transactions,
percentage:
overallTotal > 0
? Number(((entry.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
};
const buildPaymentConditionsData = (
rows: CurrentPeriodTransactionRow[],
): PaymentConditionsData => ({
conditions: buildExpenseBreakdown(rows, "condition").map((entry) => ({
condition: entry.key,
amount: entry.amount,
transactions: entry.transactions,
percentage: entry.percentage,
})),
});
const buildPaymentMethodsData = (
rows: CurrentPeriodTransactionRow[],
): PaymentMethodsData => ({
methods: buildExpenseBreakdown(rows, "paymentMethod").map((entry) => ({
paymentMethod: entry.key,
amount: entry.amount,
transactions: entry.transactions,
percentage: entry.percentage,
})),
});
const buildRecurringExpensesData = (
rows: CurrentPeriodTransactionRow[],
): RecurringExpensesData => ({
expenses: rows
.filter(
(row) =>
row.transactionType === TRANSACTION_TYPE_EXPENSE &&
row.condition === CONDITION_RECURRING &&
shouldIncludeWithoutAutoGenerated(row.note),
)
.map((row) => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
paymentMethod: row.paymentMethod,
recurrenceCount: row.recurrenceCount,
})),
});
const buildInstallmentExpensesData = (
rows: CurrentPeriodTransactionRow[],
): InstallmentExpensesData => ({
expenses: rows
.filter(
(row) =>
row.transactionType === TRANSACTION_TYPE_EXPENSE &&
row.condition === CONDITION_INSTALLMENT &&
row.isAnticipated === false &&
shouldIncludeWithoutAutoGenerated(row.note),
)
.map((row) => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
paymentMethod: row.paymentMethod,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
dueDate: row.dueDate,
purchaseDate: row.purchaseDate,
period: row.period,
}))
.sort((a, b) => {
const remainingA =
a.installmentCount && a.currentInstallment
? a.installmentCount - a.currentInstallment
: 0;
const remainingB =
b.installmentCount && b.currentInstallment
? b.installmentCount - b.currentInstallment
: 0;
return remainingA - remainingB;
}),
});
const mapTopExpense = (row: CurrentPeriodTransactionRow): TopExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
purchaseDate: row.purchaseDate,
paymentMethod: row.paymentMethod,
logo: row.cardLogo ?? row.accountLogo ?? null,
});
const buildTopExpensesData = (
rows: CurrentPeriodTransactionRow[],
cardOnly: boolean,
): TopExpensesData => ({
expenses: rows
.filter(
(row) =>
row.transactionType === TRANSACTION_TYPE_EXPENSE &&
shouldIncludeWithoutAutoGenerated(row.note) &&
(!cardOnly || row.paymentMethod === PAYMENT_METHOD_CARD),
)
.sort((a, b) => toNumber(a.amount) - toNumber(b.amount))
.slice(0, 10)
.map(mapTopExpense),
});
const buildTopEstablishmentsData = (
rows: CurrentPeriodTransactionRow[],
): TopEstablishmentsData => {
const groups = new Map<
string,
{
amount: number;
occurrences: number;
logo: string | null;
}
>();
for (const row of rows) {
if (
row.transactionType !== TRANSACTION_TYPE_EXPENSE ||
!shouldIncludeWithoutAutoGenerated(row.note)
) {
continue;
}
const current = groups.get(row.name) ?? {
amount: 0,
occurrences: 0,
logo: row.cardLogo ?? row.accountLogo ?? null,
};
current.amount += toNumber(row.amount);
current.occurrences += 1;
current.logo = current.logo ?? row.cardLogo ?? row.accountLogo ?? null;
groups.set(row.name, current);
}
const topRows = Array.from(groups.entries())
.map(([name, value]) => ({
id: name,
name,
amount: Math.abs(value.amount),
occurrences: value.occurrences,
logo: value.logo,
}))
.sort((a, b) => {
if (a.occurrences !== b.occurrences) {
return b.occurrences - a.occurrences;
}
return b.amount - a.amount;
})
.slice(0, 10);
return {
establishments: topRows.filter((row) => shouldIncludeNamedItem(row.name)),
};
};
const buildPurchasesByCategoryData = (
rows: CurrentPeriodTransactionRow[],
): PurchasesByCategoryData => {
const categoriesMap = new Map<string, CategoryOption>();
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
for (const row of rows) {
if (
!row.categoryId ||
!row.categoryName ||
!row.categoryType ||
!["despesa", "receita"].includes(row.categoryType) ||
!shouldIncludeWithoutAutoGenerated(row.note) ||
!shouldIncludeNamedItem(row.name)
) {
continue;
}
if (!categoriesMap.has(row.categoryId)) {
categoriesMap.set(row.categoryId, {
id: row.categoryId,
name: row.categoryName,
type: row.categoryType,
});
}
const transactionList = transactionsByCategory[row.categoryId] ?? [];
if (transactionList.length < 10) {
transactionList.push({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
purchaseDate: row.purchaseDate,
logo: row.cardLogo ?? row.accountLogo ?? null,
});
transactionsByCategory[row.categoryId] = transactionList;
}
}
return {
categories: Array.from(categoriesMap.values()).sort((a, b) => {
if (a.type !== b.type) {
return a.type === "receita" ? -1 : 1;
}
return a.name.localeCompare(b.name, "pt-BR");
}),
transactionsByCategory,
};
};
export async function fetchDashboardCurrentPeriodOverview(
userId: string,
period: string,
): Promise<DashboardCurrentPeriodOverview> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return emptyOverview();
}
const rows = (await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
period: transactions.period,
purchaseDate: transactions.purchaseDate,
paymentMethod: transactions.paymentMethod,
condition: transactions.condition,
currentInstallment: transactions.currentInstallment,
installmentCount: transactions.installmentCount,
recurrenceCount: transactions.recurrenceCount,
dueDate: transactions.dueDate,
boletoPaymentDate: transactions.boletoPaymentDate,
isSettled: transactions.isSettled,
transactionType: transactions.transactionType,
note: transactions.note,
isAnticipated: transactions.isAnticipated,
categoryId: transactions.categoryId,
categoryName: categories.name,
categoryType: categories.type,
cardLogo: cards.logo,
accountLogo: financialAccounts.logo,
})
.from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.payerId, adminPayerId),
),
)
.orderBy(
desc(transactions.purchaseDate),
desc(transactions.createdAt),
)) as CurrentPeriodTransactionRow[];
return {
billsSnapshot: buildBillsSnapshot(rows),
paymentStatusData: buildPaymentStatusData(rows),
paymentConditionsData: buildPaymentConditionsData(rows),
paymentMethodsData: buildPaymentMethodsData(rows),
recurringExpensesData: buildRecurringExpensesData(rows),
installmentExpensesData: buildInstallmentExpensesData(rows),
topEstablishmentsData: buildTopEstablishmentsData(rows),
topExpensesAll: buildTopExpensesData(rows, false),
topExpensesCardOnly: buildTopExpensesData(rows, true),
purchasesByCategoryData: buildPurchasesByCategoryData(rows),
};
}

View File

@@ -63,7 +63,6 @@ const ensurePeriodTotals = (
};
// Re-export for backward compatibility
export { getPreviousPeriod };
export async function fetchDashboardCardMetrics(
userId: string,

View File

@@ -1,100 +1,65 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts-queries";
import { fetchDashboardBills } from "./bills-queries";
import { fetchExpensesByCategory } from "./categories/expenses-by-category-queries";
import { fetchIncomeByCategory } from "./categories/income-by-category-queries";
import { fetchDashboardCardMetrics } from "./dashboard-metrics-queries";
import { fetchInstallmentExpenses } from "./expenses/installment-expenses-queries";
import { fetchRecurringExpenses } from "./expenses/recurring-expenses-queries";
import { fetchTopExpenses } from "./expenses/top-expenses-queries";
import { fetchGoalsProgressData } from "./goals-progress-queries";
import { fetchIncomeExpenseBalance } from "./income-expense-balance-queries";
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
import { fetchDashboardInvoices } from "./invoices-queries";
import { fetchDashboardNotes } from "./notes-queries";
import { fetchDashboardPayers } from "./payers-queries";
import { fetchPaymentConditions } from "./payments/payment-conditions-queries";
import { fetchPaymentMethods } from "./payments/payment-methods-queries";
import { fetchPaymentStatus } from "./payments/payment-status-queries";
import { fetchPurchasesByCategory } from "./purchases-by-category-queries";
import { fetchTopEstablishments } from "./top-establishments-queries";
import { fetchDashboardPeriodOverview } from "./period-overview-queries";
async function fetchDashboardDataInternal(userId: string, period: string) {
const [
metrics,
periodOverview,
accountsSnapshot,
invoicesSnapshot,
billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
currentPeriodOverview,
categoryOverview,
pagadoresSnapshot,
notesData,
paymentConditionsData,
paymentMethodsData,
recurringExpensesData,
installmentExpensesData,
topEstablishmentsData,
topExpensesAll,
topExpensesCardOnly,
purchasesByCategoryData,
incomeByCategoryData,
expensesByCategoryData,
] = await Promise.all([
fetchDashboardCardMetrics(userId, period),
fetchDashboardPeriodOverview(userId, period),
fetchDashboardAccounts(userId),
fetchDashboardInvoices(userId, period),
fetchDashboardBills(userId, period),
fetchGoalsProgressData(userId, period),
fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period),
fetchDashboardCurrentPeriodOverview(userId, period),
fetchDashboardCategoryOverview(userId, period),
fetchDashboardPayers(userId, period),
fetchDashboardNotes(userId),
fetchPaymentConditions(userId, period),
fetchPaymentMethods(userId, period),
fetchRecurringExpenses(userId, period),
fetchInstallmentExpenses(userId, period),
fetchTopEstablishments(userId, period),
fetchTopExpenses(userId, period, false),
fetchTopExpenses(userId, period, true),
fetchPurchasesByCategory(userId, period),
fetchIncomeByCategory(userId, period),
fetchExpensesByCategory(userId, period),
]);
return {
metrics,
metrics: periodOverview.metrics,
accountsSnapshot,
invoicesSnapshot,
billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
billsSnapshot: currentPeriodOverview.billsSnapshot,
goalsProgressData: categoryOverview.goalsProgressData,
paymentStatusData: currentPeriodOverview.paymentStatusData,
incomeExpenseBalanceData: periodOverview.incomeExpenseBalanceData,
pagadoresSnapshot,
notesData,
paymentConditionsData,
paymentMethodsData,
recurringExpensesData,
installmentExpensesData,
topEstablishmentsData,
topExpensesAll,
topExpensesCardOnly,
purchasesByCategoryData,
incomeByCategoryData,
expensesByCategoryData,
paymentConditionsData: currentPeriodOverview.paymentConditionsData,
paymentMethodsData: currentPeriodOverview.paymentMethodsData,
recurringExpensesData: currentPeriodOverview.recurringExpensesData,
installmentExpensesData: currentPeriodOverview.installmentExpensesData,
topEstablishmentsData: currentPeriodOverview.topEstablishmentsData,
topExpensesAll: currentPeriodOverview.topExpensesAll,
topExpensesCardOnly: currentPeriodOverview.topExpensesCardOnly,
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
incomeByCategoryData: categoryOverview.incomeByCategoryData,
expensesByCategoryData: categoryOverview.expensesByCategoryData,
};
}
/**
* Cached dashboard data fetcher.
* Uses unstable_cache with tags for revalidation on mutations.
* Cache is keyed by userId + period, and invalidated via "dashboard" tag.
* Cache is keyed by userId + period, and invalidated via user-scoped tags.
*/
export function fetchDashboardData(userId: string, period: string) {
return unstable_cache(
() => fetchDashboardDataInternal(userId, period),
[`dashboard-${userId}-${period}`],
{
tags: ["dashboard", `dashboard-${userId}`],
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();

View File

@@ -0,0 +1,67 @@
import { eq } from "drizzle-orm";
import { unstable_cache } from "next/cache";
import { payers } from "@/db/schema";
import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
type DashboardNotificationsSnapshot,
fetchDashboardNotifications,
} from "./notifications-queries";
export type DashboardNavbarData = {
pagadorAvatarUrl: string | null;
preLancamentosCount: number;
notificationsSnapshot: DashboardNotificationsSnapshot;
};
async function fetchAdminPayerAvatarUrl(
userId: string,
): Promise<string | null> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return null;
}
const payer = await db.query.payers.findFirst({
columns: {
avatarUrl: true,
},
where: eq(payers.id, adminPayerId),
});
return payer?.avatarUrl ?? null;
}
async function fetchDashboardNavbarDataInternal(
userId: string,
currentPeriod: string,
): Promise<DashboardNavbarData> {
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
await Promise.all([
fetchAdminPayerAvatarUrl(userId),
fetchDashboardNotifications(userId, currentPeriod),
fetchPendingInboxCount(userId),
]);
return {
pagadorAvatarUrl,
preLancamentosCount,
notificationsSnapshot,
};
}
export function fetchDashboardNavbarData(
userId: string,
currentPeriod: string,
) {
return unstable_cache(
() => fetchDashboardNavbarDataInternal(userId, currentPeriod),
[`dashboard-navbar-${userId}-${currentPeriod}`],
{
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
}

View File

@@ -18,39 +18,18 @@ export type DashboardNote = {
createdAt: string;
};
const parseTasks = (value: string | null): DashboardTask[] | undefined => {
function parseTasks(value: string | null): DashboardTask[] | undefined {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
return undefined;
}
return parsed
.filter((item): item is DashboardTask => {
if (!item || typeof item !== "object") {
return false;
}
const candidate = item as Partial<DashboardTask>;
return (
typeof candidate.id === "string" &&
typeof candidate.text === "string" &&
typeof candidate.completed === "boolean"
);
})
.map((task) => ({
id: task.id,
text: task.text,
completed: task.completed,
}));
} catch (error) {
console.error("Failed to parse dashboard note tasks", error);
return Array.isArray(parsed) ? parsed : undefined;
} catch {
return undefined;
}
};
}
export async function fetchDashboardNotes(
userId: string,

View File

@@ -69,80 +69,7 @@ export async function fetchDashboardNotifications(
const adminPayerId = await getAdminPayerId(userId);
// --- Faturas atrasadas (períodos anteriores) ---
const overdueInvoices = await db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
totalAmount: sql<number | null>`
COALESCE(
(SELECT SUM(${transactions.amount})
FROM ${transactions}
WHERE ${transactions.cardId} = ${cards.id}
AND ${transactions.period} = ${invoices.period}
AND ${transactions.userId} = ${invoices.userId}),
0
)
`,
})
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.where(
and(
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
);
// --- Faturas do período atual ---
const currentInvoices = await db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: sql<string>`COALESCE(${invoices.period}, ${currentPeriod})`,
paymentStatus: invoices.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cards)
.leftJoin(
invoices,
and(
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, currentPeriod),
),
)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.where(eq(cards.userId, userId))
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
);
// --- Boletos não pagos ---
// --- Build conditions that depend on adminPayerId ---
const boletosConditions = [
eq(transactions.userId, userId),
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
@@ -152,18 +79,6 @@ export async function fetchDashboardNotifications(
boletosConditions.push(eq(transactions.payerId, adminPayerId));
}
const boletosRows = await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
period: transactions.period,
})
.from(transactions)
.where(and(...boletosConditions));
// --- Orçamentos do período atual ---
const budgetJoinConditions = [
eq(transactions.categoryId, budgets.categoryId),
eq(transactions.userId, budgets.userId),
@@ -175,18 +90,116 @@ export async function fetchDashboardNotifications(
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
}
const budgetRows = await db
.select({
orcamentoId: budgets.id,
budgetAmount: budgets.amount,
categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(transactions, and(...budgetJoinConditions))
.where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
.groupBy(budgets.id, budgets.amount, categories.name);
// --- All 4 queries are independent — run in parallel ---
const [overdueInvoices, currentInvoices, boletosRows, budgetRows] =
await Promise.all([
// Faturas atrasadas (períodos anteriores)
db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
totalAmount: sql<
number | null
>`COALESCE(SUM(${transactions.amount}), 0)`,
})
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.leftJoin(
transactions,
and(
eq(transactions.cardId, invoices.cardId),
eq(transactions.period, invoices.period),
eq(transactions.userId, invoices.userId),
),
)
.where(
and(
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
)
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
),
// Faturas do período atual
db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: sql<string>`COALESCE(${invoices.period}, ${currentPeriod})`,
paymentStatus: invoices.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cards)
.leftJoin(
invoices,
and(
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, currentPeriod),
),
)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.where(eq(cards.userId, userId))
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
),
// Boletos não pagos
db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
period: transactions.period,
})
.from(transactions)
.where(and(...boletosConditions)),
// Orçamentos do período atual
db
.select({
orcamentoId: budgets.id,
budgetAmount: budgets.amount,
categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(transactions, and(...budgetJoinConditions))
.where(
and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)),
)
.groupBy(budgets.id, budgets.amount, categories.name),
]);
// =====================
// Processar notificações

View File

@@ -0,0 +1,78 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
import {
buildOptionSets,
buildSluggedFilters,
} from "@/features/transactions/page-helpers";
import {
fetchRecentEstablishments,
fetchTransactionFilterSources,
} from "@/features/transactions/queries";
export type DashboardQuickActionOptions = {
payerOptions: ReturnType<typeof buildOptionSets>["payerOptions"];
splitPayerOptions: ReturnType<typeof buildOptionSets>["splitPayerOptions"];
defaultPayerId: string | null;
accountOptions: ReturnType<typeof buildOptionSets>["accountOptions"];
cardOptions: ReturnType<typeof buildOptionSets>["cardOptions"];
categoryOptions: ReturnType<typeof buildOptionSets>["categoryOptions"];
estabelecimentos: string[];
};
async function fetchDashboardQuickActionOptionsInternal(
userId: string,
): Promise<DashboardQuickActionOptions> {
const [filterSources, estabelecimentos] = await Promise.all([
fetchTransactionFilterSources(userId),
fetchRecentEstablishments(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const {
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
} = buildOptionSets({
...sluggedFilters,
payerRows: filterSources.payerRows,
});
return {
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
estabelecimentos,
};
}
export function fetchDashboardQuickActionOptions(userId: string) {
return unstable_cache(
() => fetchDashboardQuickActionOptionsInternal(userId),
[`dashboard-quick-actions-${userId}`],
{
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
}
export async function fetchDashboardPageData(userId: string, period: string) {
const [dashboardData, preferences, quickActionOptions] = await Promise.all([
fetchDashboardData(userId, period),
fetchUserDashboardPreferences(userId),
fetchDashboardQuickActionOptions(userId),
]);
return {
dashboardData,
preferences,
quickActionOptions,
};
}

View File

@@ -0,0 +1,209 @@
import { and, asc, eq, gte, inArray, lte, ne, sum } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import type {
IncomeExpenseBalanceData,
MonthData,
} from "@/features/dashboard/income-expense-balance-queries";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
import {
addMonthsToPeriod,
buildPeriodRange,
buildPeriodWindow,
comparePeriods,
formatPeriodMonthShort,
getCurrentPeriod,
getPreviousPeriod,
} from "@/shared/utils/period";
const TRANSACTION_TYPE_INCOME = "Receita";
const TRANSACTION_TYPE_EXPENSE = "Despesa";
const TRANSACTION_TYPE_TRANSFER = "Transferência";
type PeriodTotals = {
receitas: number;
despesas: number;
balanco: number;
};
type PeriodSummaryRow = {
period: string | null;
transactionType: string;
totalAmount: string | number | null;
};
export type DashboardPeriodOverview = {
metrics: DashboardCardMetrics;
incomeExpenseBalanceData: IncomeExpenseBalanceData;
};
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
balanco: 0,
});
const ensurePeriodTotals = (
store: Map<string, PeriodTotals>,
period: string,
): PeriodTotals => {
const existing = store.get(period);
if (existing) {
return existing;
}
const totals = createEmptyTotals();
store.set(period, totals);
return totals;
};
const generateLast6Months = (currentPeriod: string): string[] => {
try {
return buildPeriodWindow(currentPeriod, 6);
} catch {
return buildPeriodWindow(getCurrentPeriod(), 6);
}
};
const emptyOverview = (period: string): DashboardPeriodOverview => {
const previousPeriod = getPreviousPeriod(period);
return {
metrics: {
period,
previousPeriod,
receitas: { current: 0, previous: 0 },
despesas: { current: 0, previous: 0 },
balanco: { current: 0, previous: 0 },
previsto: { current: 0, previous: 0 },
},
incomeExpenseBalanceData: { months: [] },
};
};
export async function fetchDashboardPeriodOverview(
userId: string,
period: string,
): Promise<DashboardPeriodOverview> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return emptyOverview(period);
}
const previousPeriod = getPreviousPeriod(period);
const chartPeriods = generateLast6Months(period);
const startPeriod = addMonthsToPeriod(period, -24);
const rows = (await db
.select({
period: transactions.period,
transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
gte(transactions.period, startPeriod),
lte(transactions.period, period),
inArray(transactions.transactionType, [
TRANSACTION_TYPE_INCOME,
TRANSACTION_TYPE_EXPENSE,
]),
ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFER),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(transactions.period, transactions.transactionType)
.orderBy(
asc(transactions.period),
asc(transactions.transactionType),
)) as PeriodSummaryRow[];
const periodTotals = new Map<string, PeriodTotals>();
for (const row of rows) {
if (!row.period) {
continue;
}
const totals = ensurePeriodTotals(periodTotals, row.period);
const total = safeToNumber(row.totalAmount);
if (row.transactionType === TRANSACTION_TYPE_INCOME) {
totals.receitas += total;
} else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) {
totals.despesas += Math.abs(total);
}
}
ensurePeriodTotals(periodTotals, period);
ensurePeriodTotals(periodTotals, previousPeriod);
const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const startRangePeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod
: previousPeriod;
const periodRange = buildPeriodRange(startRangePeriod, period);
const forecastByPeriod = new Map<string, number>();
let runningForecast = 0;
for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key);
totals.balanco = totals.receitas - totals.despesas;
runningForecast += totals.balanco;
forecastByPeriod.set(key, runningForecast);
}
const currentTotals = ensurePeriodTotals(periodTotals, period);
const previousTotals = ensurePeriodTotals(periodTotals, previousPeriod);
const months: MonthData[] = chartPeriods.map((chartPeriod) => {
const entry = periodTotals.get(chartPeriod) ?? createEmptyTotals();
return {
month: chartPeriod,
monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(),
income: entry.receitas,
expense: entry.despesas,
balance: entry.receitas - entry.despesas,
};
});
return {
metrics: {
period,
previousPeriod,
receitas: {
current: currentTotals.receitas,
previous: previousTotals.receitas,
},
despesas: {
current: currentTotals.despesas,
previous: previousTotals.despesas,
},
balanco: {
current: currentTotals.balanco,
previous: previousTotals.balanco,
},
previsto: {
current: forecastByPeriod.get(period) ?? runningForecast,
previous: forecastByPeriod.get(previousPeriod) ?? 0,
},
},
incomeExpenseBalanceData: { months },
};
}