refactor: atualiza transacoes dashboard e relatorios

This commit is contained in:
Felipe Coutinho
2026-03-14 12:51:22 +00:00
parent 43b0f0c47e
commit 6854017a8c
89 changed files with 2785 additions and 2705 deletions

View File

@@ -28,7 +28,7 @@ type CategoryBreakdownRow = {
};
type CategoryBudgetRow = {
categoriaId: string | null;
categoryId: string | null;
amount: unknown;
};
@@ -43,8 +43,8 @@ export function buildCategoryBreakdownData({
}): DashboardCategoryBreakdownData {
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
if (row.categoryId) {
budgetMap.set(row.categoryId, toNumber(row.amount));
}
}

View File

@@ -1,18 +1,23 @@
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import { mapLancamentosData } from "@/features/transactions/page-helpers";
import {
categories,
financialAccounts,
payers,
transactions,
} from "@/db/schema";
import { mapTransactionsData } from "@/features/transactions/page-helpers";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import type { CategoryType } from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { calculatePercentageChange } from "@/shared/utils/math";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
type MappedLancamentos = ReturnType<typeof mapTransactionsData>;
export type CategoryDetailData = {
category: {
@@ -34,8 +39,8 @@ export async function fetchCategoryDetails(
categoryId: string,
period: string,
): Promise<CategoryDetailData | null> {
const category = await db.query.categorias.findFirst({
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
const category = await db.query.categories.findFirst({
where: and(eq(categories.userId, userId), eq(categories.id, categoryId)),
});
if (!category) {
@@ -46,35 +51,35 @@ export async function fetchCategoryDetails(
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
const sanitizedNote = or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
);
const currentRows = await db.query.lancamentos.findMany({
const currentRows = await db.query.transactions.findMany({
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.categoriaId, categoryId),
eq(lancamentos.transactionType, transactionType),
eq(lancamentos.period, period),
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
sanitizedNote,
),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
payer: true,
financialAccount: true,
card: true,
category: true,
},
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
orderBy: [desc(transactions.purchaseDate), desc(transactions.createdAt)],
});
const filteredRows = currentRows.filter((row) => {
// Filtrar apenas pagadores admin
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
// 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.conta?.excludeInitialBalanceFromIncome
row.financialAccount?.excludeInitialBalanceFromIncome
) {
return false;
}
@@ -82,33 +87,36 @@ export async function fetchCategoryDetails(
return true;
});
const transactions = mapLancamentosData(filteredRows);
const transactionList = mapTransactionsData(filteredRows);
const currentTotal = transactions.reduce(
const currentTotal = transactionList.reduce(
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
0,
);
const [previousTotalRow] = await db
.select({
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.categoriaId, categoryId),
eq(lancamentos.transactionType, transactionType),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(payers.role, PAYER_ROLE_ADMIN),
sanitizedNote,
eq(lancamentos.period, previousPeriod),
eq(transactions.period, previousPeriod),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
);
@@ -131,6 +139,6 @@ export async function fetchCategoryDetails(
currentTotal,
previousTotal,
percentageChange,
transactions,
transactions: transactionList,
};
}

View File

@@ -1,8 +1,8 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { categories, payers, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import {
@@ -56,14 +56,14 @@ export async function fetchAllCategories(
): Promise<CategoryOption[]> {
const result = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
id: categories.id,
name: categories.name,
icon: categories.icon,
type: categories.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
.from(categories)
.where(eq(categories.userId, userId))
.orderBy(categories.type, categories.name);
return result as CategoryOption[];
}
@@ -88,36 +88,36 @@ export async function fetchCategoryHistory(
// Fetch monthly data for ALL categories with transactions
const monthlyDataQuery = (await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
totalAmount: sql<string>`SUM(ABS(${transactions.amount}))`.as(
"total_amount",
),
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(categorias.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(transactions.userId, userId),
eq(categories.userId, userId),
inArray(transactions.period, periods),
eq(payers.role, PAYER_ROLE_ADMIN),
or(
isNull(lancamentos.note),
isNull(transactions.note),
sql`${
lancamentos.note
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
transactions.period,
)) as MonthlyCategoryRow[];
if (monthlyDataQuery.length === 0) {

View File

@@ -1,5 +1,5 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { budgets, categories, transactions } from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
@@ -8,9 +8,9 @@ import {
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
@@ -22,45 +22,45 @@ export async function fetchExpensesByCategory(
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoriaId + period for both current and previous periods
// Single query: GROUP BY categoryId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({

View File

@@ -1,5 +1,10 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
@@ -9,9 +14,9 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
@@ -23,47 +28,50 @@ export async function fetchIncomeByCategory(
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoriaId + period for both current and previous periods
// Single query: GROUP BY categoryId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
eq(categorias.type, "receita"),
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({