refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets

Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/
foram movidos para subdiretórios temáticos (bills/, invoices/, notes/,
notifications/, overview/, payments/, goals-progress/, categories/).
~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura
baseada em widget-registry com components/widgets/. Novos componentes:
category-breakdown-chart/list, goals-progress-item, percentage-change-indicator.
Imports atualizados em fetch-dashboard-data e transaction-filters limpos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-20 17:51:56 +00:00
parent 3e80d5995b
commit ba05985725
99 changed files with 784 additions and 2055 deletions

View File

@@ -51,7 +51,7 @@ type UniqueCategory = {
icon: string | null;
};
export async function fetchAllCategories(
async function fetchAllCategories(
userId: string,
): Promise<CategoryOption[]> {
const result = await db

View File

@@ -0,0 +1,289 @@
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-helpers";
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/goals-progress-queries";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
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;
};
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]),
excludeTransactionsFromExcludedAccounts(),
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

@@ -1,82 +1,3 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/features/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
export async function fetchExpensesByCategory(
userId: string,
period: string,
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoryId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${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]),
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -1,84 +1,3 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/features/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
export async function fetchIncomeByCategory(
userId: string,
period: string,
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoryId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${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]),
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -0,0 +1,18 @@
export type CategoryOption = {
id: string;
name: string;
type: string;
};
export type CategoryTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
logo: string | null;
};
export type PurchasesByCategoryData = {
categories: CategoryOption[];
transactionsByCategory: Record<string, CategoryTransaction[]>;
};