diff --git a/src/features/accounts/queries.ts b/src/features/accounts/queries.ts
index c601bd3..af88cb2 100644
--- a/src/features/accounts/queries.ts
+++ b/src/features/accounts/queries.ts
@@ -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
`
- 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<{
diff --git a/src/features/budgets/queries.ts b/src/features/budgets/queries.ts
index 7ff9915..b350018 100644
--- a/src/features/budgets/queries.ts
+++ b/src/features/budgets/queries.ts
@@ -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();
- 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),
diff --git a/src/features/calendar/queries.ts b/src/features/calendar/queries.ts
index 7e8d210..02f831a 100644
--- a/src/features/calendar/queries.ts
+++ b/src/features/calendar/queries.ts
@@ -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();
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;
diff --git a/src/features/cards/queries.ts b/src/features/cards/queries.ts
index 25d5819..d94ce9b 100644
--- a/src/features/cards/queries.ts
+++ b/src/features/cards/queries.ts
@@ -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`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();
- 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<{
diff --git a/src/features/dashboard/accounts-queries.ts b/src/features/dashboard/accounts-queries.ts
index f9bb28e..624be57 100644
--- a/src/features/dashboard/accounts-queries.ts
+++ b/src/features/dashboard/accounts-queries.ts
@@ -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))
diff --git a/src/features/dashboard/categories/category-details-queries.ts b/src/features/dashboard/categories/category-details-queries.ts
index d7093a6..dcca7d8 100644
--- a/src/features/dashboard/categories/category-details-queries.ts
+++ b/src/features/dashboard/categories/category-details-queries.ts
@@ -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`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`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(
diff --git a/src/features/dashboard/category-overview-queries.ts b/src/features/dashboard/category-overview-queries.ts
new file mode 100644
index 0000000..3c06258
--- /dev/null
+++ b/src/features/dashboard/category-overview-queries.ts
@@ -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 {
+ 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`coalesce(sum(${transactions.amount}), 0)`,
+ absoluteTotal: sql`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();
+ 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,
+ };
+}
diff --git a/src/features/dashboard/components/dashboard-metrics-cards.tsx b/src/features/dashboard/components/dashboard-metrics-cards.tsx
index ecff3e6..2a901f0 100644
--- a/src/features/dashboard/components/dashboard-metrics-cards.tsx
+++ b/src/features/dashboard/components/dashboard-metrics-cards.tsx
@@ -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) {
-
+
{label}
diff --git a/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx b/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx
index c505756..da2c281 100644
--- a/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx
+++ b/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx
@@ -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();
@@ -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,
diff --git a/src/features/dashboard/components/invoices/invoice-list-item.tsx b/src/features/dashboard/components/invoices/invoice-list-item.tsx
index acd72c5..6abadb6 100644
--- a/src/features/dashboard/components/invoices/invoice-list-item.tsx
+++ b/src/features/dashboard/components/invoices/invoice-list-item.tsx
@@ -69,7 +69,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
{linkNode}
-
+
Distribuição por pagador
diff --git a/src/features/dashboard/current-period-overview-queries.ts b/src/features/dashboard/current-period-overview-queries.ts
new file mode 100644
index 0000000..6bca371
--- /dev/null
+++ b/src/features/dashboard/current-period-overview-queries.ts
@@ -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();
+ const transactionsByCategory: Record = {};
+
+ 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 {
+ 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),
+ };
+}
diff --git a/src/features/dashboard/dashboard-metrics-queries.ts b/src/features/dashboard/dashboard-metrics-queries.ts
index bc2cffa..bd509f2 100644
--- a/src/features/dashboard/dashboard-metrics-queries.ts
+++ b/src/features/dashboard/dashboard-metrics-queries.ts
@@ -63,7 +63,6 @@ const ensurePeriodTotals = (
};
// Re-export for backward compatibility
-export { getPreviousPeriod };
export async function fetchDashboardCardMetrics(
userId: string,
diff --git a/src/features/dashboard/fetch-dashboard-data.ts b/src/features/dashboard/fetch-dashboard-data.ts
index bd3696b..b0f0ec5 100644
--- a/src/features/dashboard/fetch-dashboard-data.ts
+++ b/src/features/dashboard/fetch-dashboard-data.ts
@@ -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,
},
)();
diff --git a/src/features/dashboard/navbar-queries.ts b/src/features/dashboard/navbar-queries.ts
new file mode 100644
index 0000000..3e38a11
--- /dev/null
+++ b/src/features/dashboard/navbar-queries.ts
@@ -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 {
+ 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 {
+ 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,
+ },
+ )();
+}
diff --git a/src/features/dashboard/notes-queries.ts b/src/features/dashboard/notes-queries.ts
index 0cc3fda..7c4b93e 100644
--- a/src/features/dashboard/notes-queries.ts
+++ b/src/features/dashboard/notes-queries.ts
@@ -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;
- 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,
diff --git a/src/features/dashboard/notifications-queries.ts b/src/features/dashboard/notifications-queries.ts
index fb04c36..2034bd3 100644
--- a/src/features/dashboard/notifications-queries.ts
+++ b/src/features/dashboard/notifications-queries.ts
@@ -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`
- 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`COALESCE(${invoices.period}, ${currentPeriod})`,
- paymentStatus: invoices.paymentStatus,
- totalAmount: sql`
- COALESCE(SUM(${transactions.amount}), 0)
- `,
- transactionCount: sql`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`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`COALESCE(${invoices.period}, ${currentPeriod})`,
+ paymentStatus: invoices.paymentStatus,
+ totalAmount: sql`
+ COALESCE(SUM(${transactions.amount}), 0)
+ `,
+ transactionCount: sql`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`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
diff --git a/src/features/dashboard/page-data-queries.ts b/src/features/dashboard/page-data-queries.ts
new file mode 100644
index 0000000..883db8e
--- /dev/null
+++ b/src/features/dashboard/page-data-queries.ts
@@ -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["payerOptions"];
+ splitPayerOptions: ReturnType["splitPayerOptions"];
+ defaultPayerId: string | null;
+ accountOptions: ReturnType["accountOptions"];
+ cardOptions: ReturnType["cardOptions"];
+ categoryOptions: ReturnType["categoryOptions"];
+ estabelecimentos: string[];
+};
+
+async function fetchDashboardQuickActionOptionsInternal(
+ userId: string,
+): Promise {
+ 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,
+ };
+}
diff --git a/src/features/dashboard/period-overview-queries.ts b/src/features/dashboard/period-overview-queries.ts
new file mode 100644
index 0000000..fe2359c
--- /dev/null
+++ b/src/features/dashboard/period-overview-queries.ts
@@ -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,
+ 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 {
+ 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();
+
+ 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();
+ 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 },
+ };
+}
diff --git a/src/features/notes/queries.ts b/src/features/notes/queries.ts
index 46ad810..e402e5a 100644
--- a/src/features/notes/queries.ts
+++ b/src/features/notes/queries.ts
@@ -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(),
};
diff --git a/src/features/reports/cards-report-queries.ts b/src/features/reports/cards-report-queries.ts
index a3b690d..db1a2af 100644
--- a/src/features/reports/cards-report-queries.ts
+++ b/src/features/reports/cards-report-queries.ts
@@ -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();
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 {
// 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
diff --git a/src/features/reports/establishments/queries.ts b/src/features/reports/establishments/queries.ts
index fa660dc..7b6ff32 100644
--- a/src/features/reports/establishments/queries.ts
+++ b/src/features/reports/establishments/queries.ts
@@ -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,
diff --git a/src/shared/components/navigation/navbar/app-navbar.tsx b/src/shared/components/navigation/navbar/app-navbar.tsx
index 46c3047..ecd801f 100644
--- a/src/shared/components/navigation/navbar/app-navbar.tsx
+++ b/src/shared/components/navigation/navbar/app-navbar.tsx
@@ -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 (