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

@@ -11,15 +11,9 @@ import {
sql,
sum,
} from "drizzle-orm";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { cards, categories, invoices, payers, transactions } from "@/db/schema";
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 { formatDateOnly } from "@/shared/utils/date";
import { safeToNumber } from "@/shared/utils/number";
import {
@@ -90,7 +84,7 @@ type CardRow = {
};
type CardUsageRow = {
cartaoId: string | null;
cardId: string | null;
totalAmount: unknown;
};
@@ -100,7 +94,7 @@ type MonthlyUsageRow = {
};
type CategoryAmountRow = {
categoriaId: string | null;
categoryId: string | null;
totalAmount: unknown;
};
@@ -115,7 +109,7 @@ type TopExpenseRow = {
name: string;
amount: unknown;
purchaseDate: Date | string | null;
categoriaId: string | null;
categoryId: string | null;
};
type InvoiceStatusRow = {
@@ -133,16 +127,16 @@ export async function fetchCartoesReportData(
// Fetch all active cards (not inactive)
const allCards = (await db
.select({
id: cartoes.id,
name: cartoes.name,
brand: cartoes.brand,
logo: cartoes.logo,
limit: cartoes.limit,
status: cartoes.status,
id: cards.id,
name: cards.name,
brand: cards.brand,
logo: cards.logo,
limit: cards.limit,
status: cards.status,
})
.from(cartoes)
.from(cards)
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
and(eq(cards.userId, userId), not(ilike(cards.status, "inativo"))),
)) as CardRow[];
if (allCards.length === 0) {
@@ -160,67 +154,61 @@ export async function fetchCartoesReportData(
// Fetch current period usage by card (recorrente só conta quando a data da ocorrência já passou)
const currentUsageData = (await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
or(
ne(lancamentos.condition, "Recorrente"),
sql`${lancamentos.purchaseDate} <= current_date`,
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
),
)
.groupBy(lancamentos.cartaoId)) as CardUsageRow[];
.groupBy(transactions.cardId)) as CardUsageRow[];
// Fetch previous period usage by card
const previousUsageData = (await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds),
eq(transactions.userId, userId),
eq(transactions.period, previousPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
),
)
.groupBy(lancamentos.cartaoId)) as CardUsageRow[];
.groupBy(transactions.cardId)) as CardUsageRow[];
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
if (row.cartaoId) {
currentUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
if (row.cardId) {
currentUsageMap.set(row.cardId, Math.abs(safeToNumber(row.totalAmount)));
}
}
const previousUsageMap = new Map<string, number>();
for (const row of previousUsageData) {
if (row.cartaoId) {
previousUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
if (row.cardId) {
previousUsageMap.set(row.cardId, Math.abs(safeToNumber(row.totalAmount)));
}
}
// Build card summaries
const cards: CardSummary[] = allCards.map((card) => {
const cardSummaries: CardSummary[] = allCards.map((card) => {
const limit = safeToNumber(card.limit);
const currentUsage = currentUsageMap.get(card.id) || 0;
const previousUsage = previousUsageMap.get(card.id) || 0;
@@ -252,22 +240,22 @@ export async function fetchCartoesReportData(
};
});
// Sort cards by usage (descending)
cards.sort((a, b) => b.currentUsage - a.currentUsage);
// Sort cardSummaries by usage (descending)
cardSummaries.sort((a, b) => b.currentUsage - a.currentUsage);
// Calculate totals
const totalLimit = cards.reduce((acc, c) => acc + c.limit, 0);
const totalUsage = cards.reduce((acc, c) => acc + c.currentUsage, 0);
const totalLimit = cardSummaries.reduce((acc, c) => acc + c.limit, 0);
const totalUsage = cardSummaries.reduce((acc, c) => acc + c.currentUsage, 0);
const totalUsagePercent =
totalLimit > 0 ? (totalUsage / totalLimit) * 100 : 0;
// Fetch selected card details if provided
let selectedCard: CardDetailData | null = null;
const targetCardId =
selectedCartaoId || (cards.length > 0 ? cards[0].id : null);
selectedCartaoId || (cardSummaries.length > 0 ? cardSummaries[0].id : null);
if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId);
const cardSummary = cardSummaries.find((c) => c.id === targetCardId);
if (cardSummary) {
selectedCard = await fetchCardDetail(
userId,
@@ -279,7 +267,7 @@ export async function fetchCartoesReportData(
}
return {
cards,
cards: cardSummaries,
totalLimit,
totalUsage,
totalUsagePercent,
@@ -301,23 +289,23 @@ async function fetchCardDetail(
// Fetch monthly usage
const monthlyData = (await db
.select({
period: lancamentos.period,
totalAmount: sum(lancamentos.amount).as("total"),
period: transactions.period,
totalAmount: sum(transactions.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
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(lancamentos.period)
.orderBy(lancamentos.period)) as MonthlyUsageRow[];
.groupBy(transactions.period)
.orderBy(transactions.period)) as MonthlyUsageRow[];
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
@@ -331,37 +319,37 @@ async function fetchCardDetail(
// Fetch category breakdown for current period
const categoryData = (await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(lancamentos.categoriaId)) as CategoryAmountRow[];
.groupBy(transactions.categoryId)) as CategoryAmountRow[];
// Fetch category names
const categoryIds = categoryData
.map((c) => c.categoriaId)
.map((c) => c.categoryId)
.filter((id): id is string => id !== null);
const categoryNames =
categoryIds.length > 0
? ((await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
id: categories.id,
name: categories.name,
icon: categories.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds))) as CategoryInfoRow[])
.from(categories)
.where(inArray(categories.id, categoryIds))) as CategoryInfoRow[])
: ([] as CategoryInfoRow[]);
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
@@ -374,11 +362,11 @@ async function fetchCardDetail(
const categoryBreakdown = categoryData
.map((cat) => {
const amount = Math.abs(safeToNumber(cat.totalAmount));
const catInfo = cat.categoriaId
? categoryNameMap.get(cat.categoriaId)
const catInfo = cat.categoryId
? categoryNameMap.get(cat.categoryId)
: null;
return {
id: cat.categoriaId || "sem-categoria",
id: cat.categoryId || "sem-categoria",
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
amount,
@@ -392,29 +380,29 @@ async function fetchCardDetail(
// Fetch top expenses for current period
const topExpensesData = (await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
categoriaId: lancamentos.categoriaId,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
categoryId: transactions.categoryId,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.orderBy(lancamentos.amount)
.orderBy(transactions.amount)
.limit(10)) as TopExpenseRow[];
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoriaId
? categoryNameMap.get(expense.categoriaId)
const catInfo = expense.categoryId
? categoryNameMap.get(expense.categoryId)
: null;
return {
id: expense.id,
@@ -433,19 +421,19 @@ async function fetchCardDetail(
// Fetch invoice status for last 6 months
const invoiceData = (await db
.select({
period: faturas.period,
status: faturas.paymentStatus,
period: invoices.period,
status: invoices.paymentStatus,
})
.from(faturas)
.from(invoices)
.where(
and(
eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod),
eq(invoices.userId, userId),
eq(invoices.cardId, cardId),
gte(invoices.period, startPeriod),
lte(invoices.period, currentPeriod),
),
)
.orderBy(faturas.period)) as InvoiceStatusRow[];
.orderBy(invoices.period)) as InvoiceStatusRow[];
const invoiceStatus = periods.map((period) => {
const invoice = invoiceData.find((i) => i.period === period);

View File

@@ -1,8 +1,8 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos } from "@/db/schema";
import { categories, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
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 { safeToNumber as toNumber } from "@/shared/utils/number";
import { formatPeriodMonthShort } from "@/shared/utils/period";
import { generatePeriodRange } from "./utils";
@@ -35,56 +35,56 @@ export async function fetchCategoryChartData(
): Promise<CategoryChartData> {
const periods = generatePeriodRange(startPeriod, endPeriod);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { months: [], categories: [], chartData: [], allCategories: [] };
}
const whereConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
inArray(transactions.period, periods),
or(eq(categories.type, "despesa"), eq(categories.type, "receita")),
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}%`}`,
),
];
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
whereConditions.push(inArray(categories.id, categoryIds));
}
const [rows, allCategoriesRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
categoryType: categories.type,
period: transactions.period,
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
categories.type,
transactions.period,
),
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),
]);
const allCategories = allCategoriesRows.map(
@@ -143,12 +143,12 @@ export async function fetchCategoryChartData(
formatPeriodMonthShort(period).toUpperCase(),
);
const categories = Array.from(categoryMap.values()).map((cat) => ({
const categoryList = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type,
}));
return { months, categories, chartData, allCategories };
return { months, categories: categoryList, chartData, allCategories };
}

View File

@@ -1,8 +1,8 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos } from "@/db/schema";
import { categories, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
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 { safeToNumber as toNumber } from "@/shared/utils/number";
import type {
CategoryReportData,
@@ -28,47 +28,47 @@ export async function fetchCategoryReport(
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], periods, totals: new Map(), grandTotal: 0 };
}
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
inArray(transactions.period, periods),
or(eq(categories.type, "despesa"), eq(categories.type, "receita")),
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}%`}`,
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
whereConditions.push(inArray(categories.id, categoryIds));
}
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
categoryType: categories.type,
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(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
categories.type,
transactions.period,
);
// Process results into CategoryReportData structure
@@ -171,10 +171,10 @@ export async function fetchCategoryReport(
}
// Convert to array and sort
const categories = Array.from(categoryMap.values());
const categoryList = Array.from(categoryMap.values());
// Sort: despesas first (by total desc), then receitas (by total desc)
categories.sort((a, b) => {
categoryList.sort((a, b) => {
// First by type: despesa comes before receita
if (a.type !== b.type) {
return a.type === "despesa" ? -1 : 1;
@@ -185,12 +185,12 @@ export async function fetchCategoryReport(
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
for (const categoryItem of categoryList) {
grandTotal += categoryItem.total;
}
return {
categories,
categories: categoryList,
periods,
totals: periodTotalsMap,
grandTotal,

View File

@@ -1,12 +1,10 @@
import { asc, eq } from "drizzle-orm";
import { type Categoria, categorias } from "@/db/schema";
import { type Category, categories } from "@/db/schema";
import { db } from "@/shared/lib/db";
export async function fetchUserCategories(
userId: string,
): Promise<Categoria[]> {
return db.query.categorias.findMany({
where: eq(categorias.userId, userId),
orderBy: [asc(categorias.name)],
export async function fetchUserCategories(userId: string): Promise<Category[]> {
return db.query.categories.findMany({
where: eq(categories.userId, userId),
orderBy: [asc(categories.name)],
});
}

View File

@@ -24,7 +24,7 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria
Gastos por Category
</CardTitle>
</CardHeader>
<CardContent>
@@ -45,7 +45,7 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria
Gastos por Category
</CardTitle>
</CardHeader>

View File

@@ -150,7 +150,7 @@ export function CategoryReportChart({ data }: CategoryReportChartProps) {
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Evolução por Categoria</CardTitle>
<CardTitle>Evolução por Category</CardTitle>
<CardDescription>{periodLabel}</CardDescription>
</div>
<Select value={limit} onValueChange={setLimit}>

View File

@@ -54,7 +54,7 @@ export function CategoryReportExport({
// Build CSV content
const headers = [
"Categoria",
"Category",
...data.periods.map(formatPeriodLabel),
"Total",
];
@@ -129,7 +129,7 @@ export function CategoryReportExport({
// Build data array
const headers = [
"Categoria",
"Category",
...data.periods.map(formatPeriodLabel),
"Total",
];
@@ -175,7 +175,7 @@ export function CategoryReportExport({
// Set column widths
ws["!cols"] = [
{ wch: 20 }, // Categoria
{ wch: 20 }, // Category
...data.periods.map(() => ({ wch: 15 })), // Periods
{ wch: 15 }, // Total
];
@@ -249,7 +249,7 @@ export function CategoryReportExport({
// Build table data
const headers = [
["Categoria", ...data.periods.map(formatPeriodLabel), "Total"],
["Category", ...data.periods.map(formatPeriodLabel), "Total"],
];
const body: string[][] = [];
@@ -310,7 +310,7 @@ export function CategoryReportExport({
fontStyle: "bold",
},
columnStyles: {
0: { cellWidth: 35 }, // Categoria column wider
0: { cellWidth: 35 }, // Category column wider
},
didParseCell: (cellData) => {
// Style totals row

View File

@@ -129,7 +129,7 @@ export function CategoryReportFilters({
const selectedText =
selectedCategories.length === 0
? "Categoria"
? "Category"
: selectedCategories.length === categories.length
? "Todas"
: selectedCategories.length === 1

View File

@@ -65,7 +65,7 @@ export function CategoryTable({
<TableHeader>
<TableRow>
<TableHead className="w-[240px] min-w-[240px] font-bold">
Categoria
Category
</TableHead>
{periods.map((period) => (
<TableHead

View File

@@ -13,13 +13,18 @@ import {
sql,
sum,
} from "drizzle-orm";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import {
categories,
financialAccounts,
payers,
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 { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { safeToNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
@@ -68,7 +73,7 @@ function buildPeriodRange(currentPeriod: string, months: number): string[] {
return periods;
}
export async function fetchTopEstabelecimentosData(
export async function fetchTopEstablishmentsData(
userId: string,
currentPeriod: string,
periodFilter: PeriodFilter = "6",
@@ -80,33 +85,36 @@ export async function fetchTopEstabelecimentosData(
// Fetch establishments with transaction count and total amount
const establishmentsData = await db
.select({
name: lancamentos.name,
name: transactions.name,
count: count().as("count"),
totalAmount: sum(lancamentos.amount).as("total"),
totalAmount: sum(transactions.amount).as("total"),
})
.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),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
ne(lancamentos.transactionType, TRANSFERENCIA),
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(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
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),
),
),
)
.groupBy(lancamentos.name)
.groupBy(transactions.name)
.orderBy(desc(sql`count`))
.limit(50);
@@ -117,32 +125,32 @@ export async function fetchTopEstabelecimentosData(
const categoriesByEstablishment = await db
.select({
establishmentName: lancamentos.name,
categoriaId: lancamentos.categoriaId,
establishmentName: transactions.name,
categoryId: transactions.categoryId,
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(lancamentos.name, lancamentos.categoriaId);
.groupBy(transactions.name, transactions.categoryId);
// Fetch all category names
const allCategories = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
id: categories.id,
name: categories.name,
icon: categories.icon,
})
.from(categorias)
.where(eq(categorias.userId, userId));
.from(categories)
.where(eq(categories.userId, userId));
type CategoryInfo = { id: string; name: string; icon: string | null };
const categoryMap = new Map<string, CategoryInfo>(
@@ -161,11 +169,11 @@ export async function fetchTopEstabelecimentosData(
const estCategories = categoriesByEstablishment
.filter(
(c: CategoryByEstRow) =>
c.establishmentName === est.name && c.categoriaId,
c.establishmentName === est.name && c.categoryId,
)
.map((c: CategoryByEstRow) => ({
name:
categoryMap.get(c.categoriaId as string)?.name || "Sem categoria",
categoryMap.get(c.categoryId as string)?.name || "Sem categoria",
count: Number(c.count) || 0,
}))
.sort(
@@ -189,43 +197,46 @@ export async function fetchTopEstabelecimentosData(
// Fetch top categories by spending
const topCategoriesData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("total"),
count: count().as("count"),
})
.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),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
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),
),
),
)
.groupBy(lancamentos.categoriaId)
.groupBy(transactions.categoryId)
.orderBy(sql`total ASC`)
.limit(10);
type TopCategoryRow = (typeof topCategoriesData)[0];
const topCategories: TopCategoryData[] = topCategoriesData
.filter((c: TopCategoryRow) => c.categoriaId)
.filter((c: TopCategoryRow) => c.categoryId)
.map((cat: TopCategoryRow) => {
const catInfo = categoryMap.get(cat.categoriaId as string);
const catInfo = categoryMap.get(cat.categoryId as string);
return {
id: cat.categoriaId as string,
id: cat.categoryId as string,
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
totalAmount: Math.abs(safeToNumber(cat.totalAmount)),