refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,421 +1,421 @@
import { and, eq, gte, ilike, inArray, lte, not, sum } from "drizzle-orm";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
import { and, eq, gte, ilike, inArray, lte, not, sum } from "drizzle-orm";
const DESPESA = "Despesa";
export type CardSummary = {
id: string;
name: string;
brand: string | null;
logo: string | null;
limit: number;
currentUsage: number;
usagePercent: number;
previousUsage: number;
changePercent: number;
trend: "up" | "down" | "stable";
status: string;
id: string;
name: string;
brand: string | null;
logo: string | null;
limit: number;
currentUsage: number;
usagePercent: number;
previousUsage: number;
changePercent: number;
trend: "up" | "down" | "stable";
status: string;
};
export type CardDetailData = {
card: CardSummary;
monthlyUsage: {
period: string;
periodLabel: string;
amount: number;
}[];
categoryBreakdown: {
id: string;
name: string;
icon: string | null;
amount: number;
percent: number;
}[];
topExpenses: {
id: string;
name: string;
amount: number;
date: string;
category: string | null;
}[];
invoiceStatus: {
period: string;
status: string | null;
amount: number;
}[];
card: CardSummary;
monthlyUsage: {
period: string;
periodLabel: string;
amount: number;
}[];
categoryBreakdown: {
id: string;
name: string;
icon: string | null;
amount: number;
percent: number;
}[];
topExpenses: {
id: string;
name: string;
amount: number;
date: string;
category: string | null;
}[];
invoiceStatus: {
period: string;
status: string | null;
amount: number;
}[];
};
export type CartoesReportData = {
cards: CardSummary[];
totalLimit: number;
totalUsage: number;
totalUsagePercent: number;
selectedCard: CardDetailData | null;
cards: CardSummary[];
totalLimit: number;
totalUsage: number;
totalUsagePercent: number;
selectedCard: CardDetailData | null;
};
export async function fetchCartoesReportData(
userId: string,
currentPeriod: string,
selectedCartaoId?: string | null,
userId: string,
currentPeriod: string,
selectedCartaoId?: string | null,
): Promise<CartoesReportData> {
const previousPeriod = getPreviousPeriod(currentPeriod);
const previousPeriod = getPreviousPeriod(currentPeriod);
// 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,
})
.from(cartoes)
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
);
// 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,
})
.from(cartoes)
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
);
if (allCards.length === 0) {
return {
cards: [],
totalLimit: 0,
totalUsage: 0,
totalUsagePercent: 0,
selectedCard: null,
};
}
if (allCards.length === 0) {
return {
cards: [],
totalLimit: 0,
totalUsage: 0,
totalUsagePercent: 0,
selectedCard: null,
};
}
const cardIds = allCards.map((c) => c.id);
const cardIds = allCards.map((c) => c.id);
// Fetch current period usage by card
const currentUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.cartaoId);
// Fetch current period usage by card
const currentUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.cartaoId);
// Fetch previous period usage by card
const previousUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.cartaoId);
// Fetch previous period usage by card
const previousUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.cartaoId);
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
if (row.cartaoId) {
currentUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
if (row.cartaoId) {
currentUsageMap.set(
row.cartaoId,
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)),
);
}
}
const previousUsageMap = new Map<string, number>();
for (const row of previousUsageData) {
if (row.cartaoId) {
previousUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
// Build card summaries
const cards: CardSummary[] = allCards.map((card) => {
const limit = safeToNumber(card.limit);
const currentUsage = currentUsageMap.get(card.id) || 0;
const previousUsage = previousUsageMap.get(card.id) || 0;
const usagePercent = limit > 0 ? (currentUsage / limit) * 100 : 0;
// Build card summaries
const cards: CardSummary[] = allCards.map((card) => {
const limit = safeToNumber(card.limit);
const currentUsage = currentUsageMap.get(card.id) || 0;
const previousUsage = previousUsageMap.get(card.id) || 0;
const usagePercent = limit > 0 ? (currentUsage / limit) * 100 : 0;
let changePercent = 0;
let trend: "up" | "down" | "stable" = "stable";
if (previousUsage > 0) {
changePercent = ((currentUsage - previousUsage) / previousUsage) * 100;
if (changePercent > 5) trend = "up";
else if (changePercent < -5) trend = "down";
} else if (currentUsage > 0) {
changePercent = 100;
trend = "up";
}
let changePercent = 0;
let trend: "up" | "down" | "stable" = "stable";
if (previousUsage > 0) {
changePercent = ((currentUsage - previousUsage) / previousUsage) * 100;
if (changePercent > 5) trend = "up";
else if (changePercent < -5) trend = "down";
} else if (currentUsage > 0) {
changePercent = 100;
trend = "up";
}
return {
id: card.id,
name: card.name,
brand: card.brand,
logo: card.logo,
limit,
currentUsage,
usagePercent,
previousUsage,
changePercent,
trend,
status: card.status,
};
});
return {
id: card.id,
name: card.name,
brand: card.brand,
logo: card.logo,
limit,
currentUsage,
usagePercent,
previousUsage,
changePercent,
trend,
status: card.status,
};
});
// Sort cards by usage (descending)
cards.sort((a, b) => b.currentUsage - a.currentUsage);
// Sort cards by usage (descending)
cards.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 totalUsagePercent =
totalLimit > 0 ? (totalUsage / totalLimit) * 100 : 0;
// Calculate totals
const totalLimit = cards.reduce((acc, c) => acc + c.limit, 0);
const totalUsage = cards.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);
// Fetch selected card details if provided
let selectedCard: CardDetailData | null = null;
const targetCardId =
selectedCartaoId || (cards.length > 0 ? cards[0].id : null);
if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId);
if (cardSummary) {
selectedCard = await fetchCardDetail(
userId,
targetCardId,
cardSummary,
currentPeriod,
);
}
}
if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId);
if (cardSummary) {
selectedCard = await fetchCardDetail(
userId,
targetCardId,
cardSummary,
currentPeriod,
);
}
}
return {
cards,
totalLimit,
totalUsage,
totalUsagePercent,
selectedCard,
};
return {
cards,
totalLimit,
totalUsage,
totalUsagePercent,
selectedCard,
};
}
async function fetchCardDetail(
userId: string,
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
userId: string,
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
): Promise<CardDetailData> {
// Build period range for last 12 months
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < 12; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
// Build period range for last 12 months
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < 12; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
const startPeriod = periods[0];
const startPeriod = periods[0];
const monthLabels = [
"Jan",
"Fev",
"Mar",
"Abr",
"Mai",
"Jun",
"Jul",
"Ago",
"Set",
"Out",
"Nov",
"Dez",
];
const monthLabels = [
"Jan",
"Fev",
"Mar",
"Abr",
"Mai",
"Jun",
"Jul",
"Ago",
"Set",
"Out",
"Nov",
"Dez",
];
// Fetch monthly usage
const monthlyData = await db
.select({
period: lancamentos.period,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.period)
.orderBy(lancamentos.period);
// Fetch monthly usage
const monthlyData = await db
.select({
period: lancamentos.period,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.period)
.orderBy(lancamentos.period);
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
const [year, month] = period.split("-");
return {
period,
periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`,
amount: Math.abs(safeToNumber(data?.totalAmount)),
};
});
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
const [year, month] = period.split("-");
return {
period,
periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`,
amount: Math.abs(safeToNumber(data?.totalAmount)),
};
});
// Fetch category breakdown for current period
const categoryData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.categoriaId);
// Fetch category breakdown for current period
const categoryData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.categoriaId);
// Fetch category names
const categoryIds = categoryData
.map((c) => c.categoriaId)
.filter((id): id is string => id !== null);
// Fetch category names
const categoryIds = categoryData
.map((c) => c.categoriaId)
.filter((id): id is string => id !== null);
const categoryNames =
categoryIds.length > 0
? await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds))
: [];
const categoryNames =
categoryIds.length > 0
? await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds))
: [];
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
const totalCategoryAmount = categoryData.reduce(
(acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)),
0,
);
const totalCategoryAmount = categoryData.reduce(
(acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)),
0,
);
const categoryBreakdown = categoryData
.map((cat) => {
const amount = Math.abs(safeToNumber(cat.totalAmount));
const catInfo = cat.categoriaId
? categoryNameMap.get(cat.categoriaId)
: null;
return {
id: cat.categoriaId || "sem-categoria",
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
amount,
percent:
totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
};
})
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
const categoryBreakdown = categoryData
.map((cat) => {
const amount = Math.abs(safeToNumber(cat.totalAmount));
const catInfo = cat.categoriaId
? categoryNameMap.get(cat.categoriaId)
: null;
return {
id: cat.categoriaId || "sem-categoria",
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
amount,
percent:
totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
};
})
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
// 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,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.orderBy(lancamentos.amount)
.limit(10);
// 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,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.orderBy(lancamentos.amount)
.limit(10);
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoriaId
? categoryNameMap.get(expense.categoriaId)
: null;
return {
id: expense.id,
name: expense.name,
amount: Math.abs(safeToNumber(expense.amount)),
date: expense.purchaseDate
? new Date(expense.purchaseDate).toLocaleDateString("pt-BR")
: "",
category: catInfo?.name || null,
};
});
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoriaId
? categoryNameMap.get(expense.categoriaId)
: null;
return {
id: expense.id,
name: expense.name,
amount: Math.abs(safeToNumber(expense.amount)),
date: expense.purchaseDate
? new Date(expense.purchaseDate).toLocaleDateString("pt-BR")
: "",
category: catInfo?.name || null,
};
});
// Fetch invoice status for last 6 months
const invoiceData = await db
.select({
period: faturas.period,
status: faturas.paymentStatus,
})
.from(faturas)
.where(
and(
eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod),
),
)
.orderBy(faturas.period);
// Fetch invoice status for last 6 months
const invoiceData = await db
.select({
period: faturas.period,
status: faturas.paymentStatus,
})
.from(faturas)
.where(
and(
eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod),
),
)
.orderBy(faturas.period);
const invoiceStatus = periods.map((period) => {
const invoice = invoiceData.find((i) => i.period === period);
const usage = monthlyUsage.find((m) => m.period === period);
return {
period,
status: invoice?.status || null,
amount: usage?.amount || 0,
};
});
const invoiceStatus = periods.map((period) => {
const invoice = invoiceData.find((i) => i.period === period);
const usage = monthlyUsage.find((m) => m.period === period);
return {
period,
status: invoice?.status || null,
amount: usage?.amount || 0,
};
});
return {
card: cardSummary,
monthlyUsage,
categoryBreakdown,
topExpenses,
invoiceStatus,
};
return {
card: cardSummary,
monthlyUsage,
categoryBreakdown,
topExpenses,
invoiceStatus,
};
}

View File

@@ -2,179 +2,176 @@
* Data fetching function for Category Chart (based on selected filters)
*/
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { generatePeriodRange } from "./utils";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { generatePeriodRange } from "./utils";
export type CategoryChartData = {
months: string[]; // Short month labels (e.g., "JAN", "FEV")
categories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
months: string[]; // Short month labels (e.g., "JAN", "FEV")
categories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
};
export async function fetchCategoryChartData(
userId: string,
startPeriod: string,
endPeriod: string,
categoryIds?: string[]
userId: string,
startPeriod: string,
endPeriod: string,
categoryIds?: string[],
): Promise<CategoryChartData> {
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
];
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.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(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period
);
// 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(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Map all categories
const allCategories = allCategoriesRows.map((cat: {
id: string;
name: string;
icon: string | null;
type: string;
}) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}));
// Map all categories
const allCategories = allCategoriesRows.map(
(cat: { id: string; name: string; icon: string | null; type: string }) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}),
);
// Process results into chart format
const categoryMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
dataByPeriod: Map<string, number>;
}
>();
// Process results into chart format
const categoryMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
dataByPeriod: Map<string, number>;
}
>();
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
id: categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
dataByPeriod: new Map(),
});
}
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
id: categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
dataByPeriod: new Map(),
});
}
const categoryItem = categoryMap.get(categoryId)!;
categoryItem.dataByPeriod.set(period, amount);
}
const categoryItem = categoryMap.get(categoryId)!;
categoryItem.dataByPeriod.set(period, amount);
}
// Build chart data
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
// Build chart data
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
// Add data for each category
for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
}
// Add data for each category
for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
}
return dataPoint;
});
return dataPoint;
});
// Generate month labels
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
// Generate month labels
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
// Build categories array
const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type,
}));
// Build categories array
const categories = 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,
chartData,
allCategories,
};
}

View File

@@ -2,17 +2,17 @@
* Data fetching function for Category Report
*/
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type {
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
MonthlyData,
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
MonthlyData,
} from "./types";
import { calculatePercentageChange, generatePeriodRange } from "./utils";
@@ -24,175 +24,173 @@ import { calculatePercentageChange, generatePeriodRange } from "./utils";
* @returns Complete category report data
*/
export async function fetchCategoryReport(
userId: string,
filters: CategoryReportFilters
userId: string,
filters: CategoryReportFilters,
): Promise<CategoryReportData> {
const { startPeriod, endPeriod, categoryIds } = filters;
const { startPeriod, endPeriod, categoryIds } = filters;
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
eq(categorias.type, "despesa"),
eq(categorias.type, "receita")
),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
];
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.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)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period
);
// 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)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
);
// Process results into CategoryReportData structure
const categoryMap = new Map<string, CategoryReportItem>();
const periodTotalsMap = new Map<string, number>();
// Process results into CategoryReportData structure
const categoryMap = new Map<string, CategoryReportItem>();
const periodTotalsMap = new Map<string, number>();
// Initialize period totals
for (const period of periods) {
periodTotalsMap.set(period, 0);
}
// Initialize period totals
for (const period of periods) {
periodTotalsMap.set(period, 0);
}
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } = row;
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
// Get or create category item
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
monthlyData: new Map<string, MonthlyData>(),
total: 0,
});
}
// Get or create category item
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
monthlyData: new Map<string, MonthlyData>(),
total: 0,
});
}
const categoryItem = categoryMap.get(categoryId)!;
const categoryItem = categoryMap.get(categoryId)!;
// Add monthly data (will calculate percentage later)
categoryItem.monthlyData.set(period, {
period,
amount,
previousAmount: 0, // Will be filled in next step
percentageChange: null, // Will be calculated in next step
});
// Add monthly data (will calculate percentage later)
categoryItem.monthlyData.set(period, {
period,
amount,
previousAmount: 0, // Will be filled in next step
percentageChange: null, // Will be calculated in next step
});
// Update category total
categoryItem.total += amount;
// Update category total
categoryItem.total += amount;
// Update period total
const currentPeriodTotal = periodTotalsMap.get(period) ?? 0;
periodTotalsMap.set(period, currentPeriodTotal + amount);
}
// Update period total
const currentPeriodTotal = periodTotalsMap.get(period) ?? 0;
periodTotalsMap.set(period, currentPeriodTotal + amount);
}
// Calculate percentage changes (compare with previous period)
for (const categoryItem of categoryMap.values()) {
const sortedPeriods = Array.from(categoryItem.monthlyData.keys()).sort();
// Calculate percentage changes (compare with previous period)
for (const categoryItem of categoryMap.values()) {
const sortedPeriods = Array.from(categoryItem.monthlyData.keys()).sort();
for (let i = 0; i < sortedPeriods.length; i++) {
const period = sortedPeriods[i];
const monthlyData = categoryItem.monthlyData.get(period)!;
for (let i = 0; i < sortedPeriods.length; i++) {
const period = sortedPeriods[i];
const monthlyData = categoryItem.monthlyData.get(period)!;
if (i > 0) {
// Get previous period data
const prevPeriod = sortedPeriods[i - 1];
const prevMonthlyData = categoryItem.monthlyData.get(prevPeriod);
const previousAmount = prevMonthlyData?.amount ?? 0;
if (i > 0) {
// Get previous period data
const prevPeriod = sortedPeriods[i - 1];
const prevMonthlyData = categoryItem.monthlyData.get(prevPeriod);
const previousAmount = prevMonthlyData?.amount ?? 0;
// Update with previous amount and calculate percentage
monthlyData.previousAmount = previousAmount;
monthlyData.percentageChange = calculatePercentageChange(
monthlyData.amount,
previousAmount
);
} else {
// First period - no comparison
monthlyData.previousAmount = 0;
monthlyData.percentageChange = null;
}
}
}
// Update with previous amount and calculate percentage
monthlyData.previousAmount = previousAmount;
monthlyData.percentageChange = calculatePercentageChange(
monthlyData.amount,
previousAmount,
);
} else {
// First period - no comparison
monthlyData.previousAmount = 0;
monthlyData.percentageChange = null;
}
}
}
// Fill in missing periods with zero values
for (const categoryItem of categoryMap.values()) {
for (const period of periods) {
if (!categoryItem.monthlyData.has(period)) {
// Find previous period data for percentage calculation
const periodIndex = periods.indexOf(period);
let previousAmount = 0;
// Fill in missing periods with zero values
for (const categoryItem of categoryMap.values()) {
for (const period of periods) {
if (!categoryItem.monthlyData.has(period)) {
// Find previous period data for percentage calculation
const periodIndex = periods.indexOf(period);
let previousAmount = 0;
if (periodIndex > 0) {
const prevPeriod = periods[periodIndex - 1];
const prevData = categoryItem.monthlyData.get(prevPeriod);
previousAmount = prevData?.amount ?? 0;
}
if (periodIndex > 0) {
const prevPeriod = periods[periodIndex - 1];
const prevData = categoryItem.monthlyData.get(prevPeriod);
previousAmount = prevData?.amount ?? 0;
}
categoryItem.monthlyData.set(period, {
period,
amount: 0,
previousAmount,
percentageChange: calculatePercentageChange(0, previousAmount),
});
}
}
}
categoryItem.monthlyData.set(period, {
period,
amount: 0,
previousAmount,
percentageChange: calculatePercentageChange(0, previousAmount),
});
}
}
}
// Convert to array and sort
const categories = Array.from(categoryMap.values());
// Convert to array and sort
const categories = Array.from(categoryMap.values());
// Sort: despesas first (by total desc), then receitas (by total desc)
categories.sort((a, b) => {
// First by type: despesa comes before receita
if (a.type !== b.type) {
return a.type === "despesa" ? -1 : 1;
}
// Then by total (descending)
return b.total - a.total;
});
// Sort: despesas first (by total desc), then receitas (by total desc)
categories.sort((a, b) => {
// First by type: despesa comes before receita
if (a.type !== b.type) {
return a.type === "despesa" ? -1 : 1;
}
// Then by total (descending)
return b.total - a.total;
});
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
grandTotal += categoryItem.total;
}
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
grandTotal += categoryItem.total;
}
return {
categories,
periods,
totals: periodTotalsMap,
grandTotal,
};
return {
categories,
periods,
totals: periodTotalsMap,
grandTotal,
};
}

View File

@@ -6,47 +6,47 @@
* Monthly data for a specific category in a specific period
*/
export type MonthlyData = {
period: string; // Format: "YYYY-MM"
amount: number; // Total amount for this category in this period
previousAmount: number; // Amount from previous period (for comparison)
percentageChange: number | null; // Percentage change from previous period
period: string; // Format: "YYYY-MM"
amount: number; // Total amount for this category in this period
previousAmount: number; // Amount from previous period (for comparison)
percentageChange: number | null; // Percentage change from previous period
};
/**
* Single category item in the report
*/
export type CategoryReportItem = {
categoryId: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
total: number; // Total across all periods
categoryId: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
total: number; // Total across all periods
};
/**
* Complete category report data structure
*/
export type CategoryReportData = {
categories: CategoryReportItem[]; // All categories with their data
periods: string[]; // All periods in the report (sorted chronologically)
totals: Map<string, number>; // Total per period across all categories
grandTotal: number; // Total of all categories and all periods
categories: CategoryReportItem[]; // All categories with their data
periods: string[]; // All periods in the report (sorted chronologically)
totals: Map<string, number>; // Total per period across all categories
grandTotal: number; // Total of all categories and all periods
};
/**
* Filters for category report query
*/
export type CategoryReportFilters = {
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
categoryIds?: string[]; // Optional: filter by specific categories
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
categoryIds?: string[]; // Optional: filter by specific categories
};
/**
* Validation result for date range
*/
export type DateRangeValidation = {
isValid: boolean;
error?: string;
isValid: boolean;
error?: string;
};

View File

@@ -2,9 +2,9 @@
* Utility functions for Category Report feature
*/
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
import { calculatePercentageChange } from "@/lib/utils/math";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { calculatePercentageChange } from "@/lib/utils/math";
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
import type { DateRangeValidation } from "./types";
// Re-export for convenience
@@ -18,18 +18,18 @@ export { calculatePercentageChange };
* @returns Formatted period string
*/
export function formatPeriodLabel(period: string): string {
try {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1];
try {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1];
// Capitalize first letter and take first 3 chars
const shortMonth =
monthName.charAt(0).toUpperCase() + monthName.slice(1, 3);
// Capitalize first letter and take first 3 chars
const shortMonth =
monthName.charAt(0).toUpperCase() + monthName.slice(1, 3);
return `${shortMonth}/${year}`;
} catch {
return period; // Return original if parsing fails
}
return `${shortMonth}/${year}`;
} catch {
return period; // Return original if parsing fails
}
}
/**
@@ -41,10 +41,10 @@ export function formatPeriodLabel(period: string): string {
* @returns Array of period strings in chronological order
*/
export function generatePeriodRange(
startPeriod: string,
endPeriod: string
startPeriod: string,
endPeriod: string,
): string[] {
return buildPeriodRange(startPeriod, endPeriod);
return buildPeriodRange(startPeriod, endPeriod);
}
/**
@@ -56,47 +56,47 @@ export function generatePeriodRange(
* @returns Validation result with error message if invalid
*/
export function validateDateRange(
startPeriod: string,
endPeriod: string
startPeriod: string,
endPeriod: string,
): DateRangeValidation {
try {
// Parse periods to validate format
const start = parsePeriod(startPeriod);
const end = parsePeriod(endPeriod);
try {
// Parse periods to validate format
const start = parsePeriod(startPeriod);
const end = parsePeriod(endPeriod);
// Check if end is before start
if (
end.year < start.year ||
(end.year === start.year && end.month < start.month)
) {
return {
isValid: false,
error: "A data final deve ser maior ou igual à data inicial",
};
}
// Check if end is before start
if (
end.year < start.year ||
(end.year === start.year && end.month < start.month)
) {
return {
isValid: false,
error: "A data final deve ser maior ou igual à data inicial",
};
}
// Calculate number of months between periods
const monthsDiff =
(end.year - start.year) * 12 + (end.month - start.month) + 1;
// Calculate number of months between periods
const monthsDiff =
(end.year - start.year) * 12 + (end.month - start.month) + 1;
// Check if period exceeds 24 months
if (monthsDiff > 24) {
return {
isValid: false,
error: "O período máximo permitido é de 24 meses",
};
}
// Check if period exceeds 24 months
if (monthsDiff > 24) {
return {
isValid: false,
error: "O período máximo permitido é de 24 meses",
};
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Formato de período inválido. Use YYYY-MM",
};
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Formato de período inválido. Use YYYY-MM",
};
}
}
/**
@@ -107,7 +107,7 @@ export function validateDateRange(
* @returns Formatted currency string
*/
export function formatCurrency(value: number): string {
return currencyFormatter.format(value);
return currencyFormatter.format(value);
}
/**
@@ -118,14 +118,14 @@ export function formatCurrency(value: number): string {
* @returns Formatted percentage string
*/
export function formatPercentageChange(change: number | null): string {
if (change === null) return "-";
if (change === null) return "-";
const absChange = Math.abs(change);
const sign = change >= 0 ? "+" : "-";
const absChange = Math.abs(change);
const sign = change >= 0 ? "+" : "-";
// Use one decimal place if less than 10%
const formatted =
absChange < 10 ? absChange.toFixed(1) : Math.round(absChange).toString();
// Use one decimal place if less than 10%
const formatted =
absChange < 10 ? absChange.toFixed(1) : Math.round(absChange).toString();
return `${sign}${formatted}%`;
return `${sign}${formatted}%`;
}