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

@@ -1,17 +1,8 @@
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
import {
buildOptionSets,
buildSluggedFilters,
getSingleParam,
} from "@/features/transactions/page-helpers";
import {
fetchRecentEstablishments,
fetchTransactionFilterSources,
} from "@/features/transactions/queries";
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/page-helpers";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -28,26 +19,9 @@ export default async function Page({ searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [dashboardData, preferences, filterSources, estabelecimentos] =
await Promise.all([
fetchDashboardData(user.id, selectedPeriod),
fetchUserDashboardPreferences(user.id),
fetchTransactionFilterSources(user.id),
fetchRecentEstablishments(user.id),
]);
const { dashboardData, preferences, quickActionOptions } =
await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences;
const sluggedFilters = buildSluggedFilters(filterSources);
const {
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
} = buildOptionSets({
...sluggedFilters,
payerRows: filterSources.payerRows,
});
return (
<main className="flex flex-col gap-4">
@@ -58,15 +32,7 @@ export default async function Page({ searchParams }: PageProps) {
data={dashboardData}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={{
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
estabelecimentos,
}}
quickActionOptions={quickActionOptions}
/>
</main>
);

View File

@@ -1,11 +1,8 @@
import { fetchDashboardNotifications } from "@/features/dashboard/notifications-queries";
import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server";
import { fetchPayersWithAccess } from "@/shared/lib/payers/access";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { parsePeriodParam } from "@/shared/utils/period";
export default async function DashboardLayout({
@@ -16,12 +13,6 @@ export default async function DashboardLayout({
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}>) {
const session = await getUserSession();
const payerList = await fetchPayersWithAccess(session.user.id);
// Encontrar o pagador admin do usuário
const adminPagador = payerList.find(
(p) => p.role === PAYER_ROLE_ADMIN && p.userId === session.user.id,
);
// Buscar notificações para o período atual
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -35,18 +26,18 @@ export default async function DashboardLayout({
const { period: currentPeriod } = parsePeriodParam(
singlePeriodoParam ?? null,
);
const [notificationsSnapshot, preLancamentosCount] = await Promise.all([
fetchDashboardNotifications(session.user.id, currentPeriod),
fetchPendingInboxCount(session.user.id),
]);
const navbarData = await fetchDashboardNavbarData(
session.user.id,
currentPeriod,
);
return (
<PrivacyProvider>
<AppNavbar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
preLancamentosCount={preLancamentosCount}
notificationsSnapshot={notificationsSnapshot}
pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
preLancamentosCount={navbarData.preLancamentosCount}
notificationsSnapshot={navbarData.notificationsSnapshot}
/>
<div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">

View File

@@ -1,9 +1,9 @@
import { and, eq, ilike, not, sql } from "drizzle-orm";
import { financialAccounts, payers, transactions } from "@/db/schema";
import { financialAccounts, transactions } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AccountData = {
id: string;
@@ -18,9 +18,12 @@ export type AccountData = {
excludeInitialBalanceFromIncome: boolean;
};
export async function fetchAccountsForUser(
async function fetchAccountsByStatus(
userId: string,
archived: boolean,
): Promise<{ accounts: AccountData[]; logoOptions: string[] }> {
const adminPayerId = await getAdminPayerId(userId);
const [accountRows, logoOptions] = await Promise.all([
db
.select({
@@ -53,14 +56,15 @@ export async function fetchAccountsForUser(
eq(transactions.accountId, financialAccounts.id),
eq(transactions.userId, userId),
eq(transactions.isSettled, true),
adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`,
),
)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(financialAccounts.userId, userId),
not(ilike(financialAccounts.status, "inativa")),
sql`(${transactions.id} IS NULL OR ${payers.role} = ${PAYER_ROLE_ADMIN})`,
archived
? ilike(financialAccounts.status, "inativa")
: not(ilike(financialAccounts.status, "inativa")),
),
)
.groupBy(
@@ -95,81 +99,16 @@ export async function fetchAccountsForUser(
return { accounts, logoOptions };
}
export async function fetchAccountsForUser(
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: string[] }> {
return fetchAccountsByStatus(userId, false);
}
export async function fetchInactiveForUser(
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: string[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: financialAccounts.id,
name: financialAccounts.name,
accountType: financialAccounts.accountType,
status: financialAccounts.status,
note: financialAccounts.note,
logo: financialAccounts.logo,
initialBalance: financialAccounts.initialBalance,
excludeFromBalance: financialAccounts.excludeFromBalance,
excludeInitialBalanceFromIncome:
financialAccounts.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${transactions.amount}
end
),
0
)
`,
})
.from(financialAccounts)
.leftJoin(
transactions,
and(
eq(transactions.accountId, financialAccounts.id),
eq(transactions.userId, userId),
eq(transactions.isSettled, true),
),
)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(financialAccounts.userId, userId),
ilike(financialAccounts.status, "inativa"),
sql`(${transactions.id} IS NULL OR ${payers.role} = ${PAYER_ROLE_ADMIN})`,
),
)
.groupBy(
financialAccounts.id,
financialAccounts.name,
financialAccounts.accountType,
financialAccounts.status,
financialAccounts.note,
financialAccounts.logo,
financialAccounts.initialBalance,
financialAccounts.excludeFromBalance,
financialAccounts.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };
return fetchAccountsByStatus(userId, true);
}
export async function fetchAllAccountsForUser(userId: string): Promise<{

View File

@@ -1,8 +1,8 @@
import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm";
import { budgets, categories, payers, transactions } from "@/db/schema";
import { budgets, categories, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") return value;
@@ -39,6 +39,8 @@ export async function fetchBudgetsForUser(
budgets: BudgetData[];
categoriesOptions: CategoryOption[];
}> {
const adminPayerId = await getAdminPayerId(userId);
const [budgetRows, categoryRows] = await Promise.all([
db.query.budgets.findMany({
where: and(
@@ -66,20 +68,19 @@ export async function fetchBudgetsForUser(
let totalsByCategory = new Map<string, number>();
if (categoryIds.length > 0) {
if (categoryIds.length > 0 && adminPayerId) {
const totals = await db
.select({
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("totalAmount"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, selectedPeriod),
eq(transactions.transactionType, "Despesa"),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.payerId, adminPayerId),
inArray(transactions.categoryId, categoryIds),
or(
isNull(transactions.note),

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
import { and, eq, gte, lte, ne, or, sql } from "drizzle-orm";
import { cards, transactions } from "@/db/schema";
import {
buildOptionSets,
@@ -10,7 +10,7 @@ import {
fetchTransactionFilterSources,
} from "@/features/transactions/queries";
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 type { CalendarData, CalendarEvent } from "@/shared/lib/types/calendar";
import { formatDateKey } from "@/shared/utils/calendar";
import { parsePeriod } from "@/shared/utils/period";
@@ -45,11 +45,13 @@ export const fetchCalendarData = async ({
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
const rangeStartKey = formatDateKey(rangeStart);
const rangeEndKey = formatDateKey(rangeEnd);
const adminPayerId = await getAdminPayerId(userId);
const [transactionRows, cardRows, filterSources] = await Promise.all([
db.query.transactions.findMany({
where: and(
eq(transactions.userId, userId),
adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`,
ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
or(
// Lançamentos cuja data de compra esteja no período do calendário
@@ -88,11 +90,7 @@ export const fetchCalendarData = async ({
const cardTotals = new Map<string, number>();
for (const item of transactionData) {
if (
!item.cardId ||
item.period !== period ||
item.pagadorRole !== PAYER_ROLE_ADMIN
) {
if (!item.cardId || item.period !== period) {
continue;
}
const amount = Math.abs(item.amount ?? 0);
@@ -101,12 +99,10 @@ export const fetchCalendarData = async ({
for (const item of transactionData) {
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
const isAdminPagador = item.pagadorRole === PAYER_ROLE_ADMIN;
// Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin
// Para boletos, exibir apenas na data de vencimento
if (isBoleto) {
if (
isAdminPagador &&
item.dueDate &&
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
) {
@@ -119,9 +115,6 @@ export const fetchCalendarData = async ({
}
} else {
// Para outros tipos de lançamento, exibir na data de compra
if (!isAdminPagador) {
continue;
}
const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
events.push({
@@ -134,7 +127,7 @@ export const fetchCalendarData = async ({
}
}
// Exibir vencimentos apenas de cartões com lançamentos do pagador admin
// Exibir vencimentos apenas de cartões com lançamentos do período
for (const card of cardRows) {
if (!cardTotals.has(card.id)) {
continue;

View File

@@ -25,7 +25,10 @@ export type AccountSimple = {
logo: string | null;
};
export async function fetchCardsForUser(userId: string): Promise<{
async function fetchCardsByStatus(
userId: string,
archived: boolean,
): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: string[];
@@ -33,7 +36,12 @@ export async function fetchCardsForUser(userId: string): Promise<{
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(eq(cards.userId, userId), not(ilike(cards.status, "inativo"))),
where: and(
eq(cards.userId, userId),
archived
? ilike(cards.status, "inativo")
: not(ilike(cards.status, "inativo")),
),
with: {
financialAccount: {
columns: {
@@ -116,95 +124,20 @@ export async function fetchCardsForUser(userId: string): Promise<{
return { cards: cardList, accounts, logoOptions };
}
export async function fetchCardsForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: string[];
}> {
return fetchCardsByStatus(userId, false);
}
export async function fetchInactiveForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: string[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(eq(cards.userId, userId), ilike(cards.status, "inativo")),
with: {
financialAccount: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.financialAccounts.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: eq(financialAccounts.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
),
)
.groupBy(transactions.cardId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
if (!row.cardId) return;
usageMap.set(row.cardId, Number(row.total ?? 0));
});
const cardList = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand ?? "",
status: card.status ?? "",
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
accountId: card.accountId,
accountName:
(card.financialAccount as { name?: string } | null)?.name ??
"Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards: cardList, accounts, logoOptions };
return fetchCardsByStatus(userId, true);
}
export async function fetchAllCardsForUser(userId: string): Promise<{

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

View File

@@ -18,21 +18,26 @@ export type NoteData = {
createdAt: string;
};
function toNoteData(note: Note): NoteData {
let tasks: Task[] | undefined;
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
}
function parseTasks(value: string | null): Task[] | undefined {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function toNoteData(note: Note): NoteData {
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
tasks: parseTasks(note.tasks),
archived: note.archived,
createdAt: note.createdAt.toISOString(),
};

View File

@@ -8,12 +8,11 @@ import {
ne,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { cards, categories, invoices, payers, transactions } from "@/db/schema";
import { cards, categories, invoices, transactions } from "@/db/schema";
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 { formatDateOnly } from "@/shared/utils/date";
import { safeToNumber } from "@/shared/utils/number";
import {
@@ -150,48 +149,51 @@ export async function fetchCartoesReportData(
}
const cardIds = allCards.map((c) => c.id);
const adminPayerId = await getAdminPayerId(userId);
// Fetch current period usage by card (recorrente só conta quando a data da ocorrência já passou)
const currentUsageData = (await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[];
const currentUsageData = adminPayerId
? ((await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
or(
ne(transactions.condition, "Recorrente"),
lte(transactions.purchaseDate, new Date()),
),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[])
: [];
// Fetch previous period usage by card
const previousUsageData = (await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, previousPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[];
const previousUsageData = adminPayerId
? ((await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, previousPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[])
: [];
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
@@ -262,6 +264,7 @@ export async function fetchCartoesReportData(
targetCardId,
cardSummary,
currentPeriod,
adminPayerId,
);
}
}
@@ -280,6 +283,7 @@ async function fetchCardDetail(
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
adminPayerId: string | null,
): Promise<CardDetailData> {
// Build period range for last 12 months
const periods = buildPeriodWindow(currentPeriod, 12);
@@ -287,25 +291,26 @@ async function fetchCardDetail(
const startPeriod = periods[0];
// Fetch monthly usage
const monthlyData = (await db
.select({
period: transactions.period,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.period)
.orderBy(transactions.period)) as MonthlyUsageRow[];
const monthlyData = adminPayerId
? ((await db
.select({
period: transactions.period,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.period)
.orderBy(transactions.period)) as MonthlyUsageRow[])
: [];
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
@@ -317,23 +322,24 @@ async function fetchCardDetail(
});
// Fetch category breakdown for current period
const categoryData = (await db
.select({
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.categoryId)) as CategoryAmountRow[];
const categoryData = adminPayerId
? ((await db
.select({
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.categoryId)) as CategoryAmountRow[])
: [];
// Fetch category names
const categoryIds = categoryData
@@ -378,27 +384,28 @@ async function fetchCardDetail(
.slice(0, 10);
// Fetch top expenses for current period
const topExpensesData = (await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
categoryId: transactions.categoryId,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.orderBy(transactions.amount)
.limit(10)) as TopExpenseRow[];
const topExpensesData = adminPayerId
? ((await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
categoryId: transactions.categoryId,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
),
)
.orderBy(transactions.amount)
.limit(10)) as TopExpenseRow[])
: [];
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoryId

View File

@@ -5,6 +5,7 @@ import {
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
@@ -13,23 +14,17 @@ import {
sql,
sum,
} from "drizzle-orm";
import {
categories,
financialAccounts,
payers,
transactions,
} from "@/db/schema";
import { categories, financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/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 { safeToNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
const DESPESA = "Despesa";
const TRANSFERENCIA = "Transferência";
export type EstablishmentData = {
name: string;
@@ -81,6 +76,48 @@ export async function fetchTopEstablishmentsData(
const months = parseInt(periodFilter, 10);
const periods = buildPeriodRange(currentPeriod, months);
const startPeriod = periods[0];
const adminPayerId = await getAdminPayerId(userId);
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
if (!adminPayerId) {
return {
establishments: [],
topCategories: [],
summary: {
totalEstablishments: 0,
totalTransactions: 0,
totalSpent: 0,
avgPerTransaction: 0,
mostFrequent: null,
highestSpending: null,
},
periodLabel,
};
}
const baseExpenseConditions = [
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
] as const;
const exclusionConditions = [
or(
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
] as const;
// Fetch establishments with transaction count and total amount
const establishmentsData = await db
@@ -90,57 +127,41 @@ export async function fetchTopEstablishmentsData(
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
ne(transactions.transactionType, TRANSFERENCIA),
or(
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
)
.where(and(...baseExpenseConditions, ...exclusionConditions))
.groupBy(transactions.name)
.orderBy(desc(sql`count`))
.limit(50);
// Fetch categories for each establishment
const _establishmentNames = establishmentsData.map(
(e: (typeof establishmentsData)[0]) => e.name,
);
const establishmentNames = establishmentsData
.map((est) => est.name)
.filter((name): name is string => !!name);
const categoriesByEstablishment = await db
.select({
establishmentName: transactions.name,
categoryId: transactions.categoryId,
count: count().as("count"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.name, transactions.categoryId);
const categoriesByEstablishment =
establishmentNames.length > 0
? await db
.select({
establishmentName: transactions.name,
categoryId: transactions.categoryId,
count: count().as("count"),
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...baseExpenseConditions,
...exclusionConditions,
inArray(transactions.name, establishmentNames),
),
)
.groupBy(transactions.name, transactions.categoryId)
: [];
// Fetch all category names
const allCategories = await db
@@ -159,23 +180,33 @@ export async function fetchTopEstablishmentsData(
// Build establishment data with categories
type EstablishmentRow = (typeof establishmentsData)[0];
type CategoryByEstRow = (typeof categoriesByEstablishment)[0];
const categoriesByEstablishmentMap = new Map<
string,
Array<{ name: string; count: number }>
>();
for (const categoryRow of categoriesByEstablishment) {
if (!categoryRow.establishmentName || !categoryRow.categoryId) {
continue;
}
const current =
categoriesByEstablishmentMap.get(categoryRow.establishmentName) ?? [];
current.push({
name:
categoryMap.get(categoryRow.categoryId as string)?.name ||
"Sem categoria",
count: Number(categoryRow.count) || 0,
});
categoriesByEstablishmentMap.set(categoryRow.establishmentName, current);
}
const establishments: EstablishmentData[] = establishmentsData.map(
(est: EstablishmentRow) => {
const cnt = Number(est.count) || 0;
const total = Math.abs(safeToNumber(est.totalAmount));
const estCategories = categoriesByEstablishment
.filter(
(c: CategoryByEstRow) =>
c.establishmentName === est.name && c.categoryId,
)
.map((c: CategoryByEstRow) => ({
name:
categoryMap.get(c.categoryId as string)?.name || "Sem categoria",
count: Number(c.count) || 0,
}))
const estCategories = (categoriesByEstablishmentMap.get(est.name) ?? [])
.sort(
(
a: { name: string; count: number },
@@ -202,29 +233,11 @@ export async function fetchTopEstablishmentsData(
count: count().as("count"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
or(
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
)
.where(and(...baseExpenseConditions, ...exclusionConditions))
.groupBy(transactions.categoryId)
.orderBy(sql`total ASC`)
.limit(10);
@@ -257,13 +270,6 @@ export async function fetchTopEstablishmentsData(
const highestSpending =
sortedBySpending.length > 0 ? sortedBySpending[0].name : null;
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
return {
establishments,
topCategories,

View File

@@ -7,9 +7,6 @@ import { RefreshPageButton } from "@/shared/components/refresh-page-button";
import { NavMenu } from "./nav-menu";
import { NavbarUser } from "./navbar-user";
const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
type AppNavbarProps = {
user: {
id: string;
@@ -22,6 +19,9 @@ type AppNavbarProps = {
notificationsSnapshot: DashboardNotificationsSnapshot;
};
const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export function AppNavbar({
user,
pagadorAvatarUrl,
@@ -30,10 +30,6 @@ export function AppNavbar({
}: AppNavbarProps) {
return (
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-0 bg-linear-to-b from-white/8 via-transparent to-black/6" />
</div>
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
<Link href="/dashboard" className="shrink-0 mr-1">
<Logo variant="compact" invertTextOnDark={false} />