mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-03-10 04:51:47 +00:00
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:
@@ -1,148 +1,152 @@
|
||||
import { categorias, lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import type { CategoryType } from "@/lib/categorias/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { and, desc, eq, isNull, or, sql, ne } from "drizzle-orm";
|
||||
|
||||
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||
|
||||
export type CategoryDetailData = {
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactions: MappedLancamentos;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactions: MappedLancamentos;
|
||||
};
|
||||
|
||||
const calculatePercentageChange = (
|
||||
current: number,
|
||||
previous: number
|
||||
current: number,
|
||||
previous: number,
|
||||
): number | null => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
};
|
||||
|
||||
export async function fetchCategoryDetails(
|
||||
userId: string,
|
||||
categoryId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
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.categorias.findFirst({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
|
||||
const sanitizedNote = or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
);
|
||||
const sanitizedNote = or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
);
|
||||
|
||||
const currentRows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(lancamentos.period, period),
|
||||
sanitizedNote
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
const currentRows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(lancamentos.period, period),
|
||||
sanitizedNote,
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
|
||||
const filteredRows = currentRows.filter(
|
||||
(row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
const filteredRows = currentRows.filter((row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (row.note === INITIAL_BALANCE_NOTE && row.conta?.excludeInitialBalanceFromIncome) {
|
||||
return false;
|
||||
}
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (
|
||||
row.note === INITIAL_BALANCE_NOTE &&
|
||||
row.conta?.excludeInitialBalanceFromIncome
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
|
||||
const currentTotal = transactions.reduce(
|
||||
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
|
||||
0
|
||||
);
|
||||
const currentTotal = transactions.reduce(
|
||||
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
|
||||
0,
|
||||
);
|
||||
|
||||
const [previousTotalRow] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.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)
|
||||
)
|
||||
)
|
||||
);
|
||||
const [previousTotalRow] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentTotal,
|
||||
previousTotal
|
||||
);
|
||||
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
);
|
||||
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
type: category.type as CategoryType,
|
||||
},
|
||||
period,
|
||||
previousPeriod,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactions,
|
||||
};
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
type: category.type as CategoryType,
|
||||
},
|
||||
period,
|
||||
previousPeriod,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { addMonths, 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";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
};
|
||||
|
||||
export type CategoryHistoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
};
|
||||
|
||||
export type CategoryHistoryData = {
|
||||
months: string[]; // ["NOV", "DEZ", "JAN", ...]
|
||||
categories: CategoryHistoryItem[];
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: CategoryOption[];
|
||||
months: string[]; // ["NOV", "DEZ", "JAN", ...]
|
||||
categories: CategoryHistoryItem[];
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: CategoryOption[];
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
"#ef4444", // red-500
|
||||
"#3b82f6", // blue-500
|
||||
"#10b981", // emerald-500
|
||||
"#f59e0b", // amber-500
|
||||
"#8b5cf6", // violet-500
|
||||
"#ef4444", // red-500
|
||||
"#3b82f6", // blue-500
|
||||
"#10b981", // emerald-500
|
||||
"#f59e0b", // amber-500
|
||||
"#8b5cf6", // violet-500
|
||||
];
|
||||
|
||||
export async function fetchAllCategories(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<CategoryOption[]> {
|
||||
const result = 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);
|
||||
const result = 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);
|
||||
|
||||
return result as CategoryOption[];
|
||||
return result as CategoryOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,141 +62,141 @@ export async function fetchAllCategories(
|
||||
* Widget will allow user to select up to 5 to display
|
||||
*/
|
||||
export async function fetchCategoryHistory(
|
||||
userId: string,
|
||||
currentPeriod: string
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<CategoryHistoryData> {
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
const periods: string[] = [];
|
||||
const monthLabels: string[] = [];
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
const periods: string[] = [];
|
||||
const monthLabels: string[] = [];
|
||||
|
||||
const [year, month] = currentPeriod.split("-").map(Number);
|
||||
const currentDate = new Date(year, month - 1, 1);
|
||||
const [year, month] = currentPeriod.split("-").map(Number);
|
||||
const currentDate = new Date(year, month - 1, 1);
|
||||
|
||||
// Generate months from -8 to +1 (relative to current)
|
||||
for (let i = 8; i >= -1; i--) {
|
||||
const date = addMonths(currentDate, -i);
|
||||
const period = format(date, "yyyy-MM");
|
||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
periods.push(period);
|
||||
monthLabels.push(label);
|
||||
}
|
||||
// Generate months from -8 to +1 (relative to current)
|
||||
for (let i = 8; i >= -1; i--) {
|
||||
const date = addMonths(currentDate, -i);
|
||||
const period = format(date, "yyyy-MM");
|
||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
periods.push(period);
|
||||
monthLabels.push(label);
|
||||
}
|
||||
|
||||
// Fetch all categories for the selector
|
||||
const allCategories = await fetchAllCategories(userId);
|
||||
// Fetch all categories for the selector
|
||||
const allCategories = await fetchAllCategories(userId);
|
||||
|
||||
// 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(
|
||||
"total_amount"
|
||||
),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(categorias.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period
|
||||
);
|
||||
// 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(
|
||||
"total_amount",
|
||||
),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(categorias.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
);
|
||||
|
||||
if (monthlyDataQuery.length === 0) {
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: [],
|
||||
chartData: monthLabels.map((month) => ({ month })),
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
if (monthlyDataQuery.length === 0) {
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: [],
|
||||
chartData: monthLabels.map((month) => ({ month })),
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
|
||||
// Get unique categories from query results
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(
|
||||
monthlyDataQuery.map((row) => [
|
||||
row.categoryId,
|
||||
{
|
||||
id: row.categoryId,
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
},
|
||||
])
|
||||
).values()
|
||||
);
|
||||
// Get unique categories from query results
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(
|
||||
monthlyDataQuery.map((row) => [
|
||||
row.categoryId,
|
||||
{
|
||||
id: row.categoryId,
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
// Transform data into chart-ready format
|
||||
const categoriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
// Transform data into chart-ready format
|
||||
const categoriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
periods.forEach((period, periodIndex) => {
|
||||
monthData[monthLabels[periodIndex]] = 0;
|
||||
});
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
periods.forEach((_period, periodIndex) => {
|
||||
monthData[monthLabels[periodIndex]] = 0;
|
||||
});
|
||||
|
||||
categoriesMap.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
data: monthData,
|
||||
});
|
||||
});
|
||||
categoriesMap.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
data: monthData,
|
||||
});
|
||||
});
|
||||
|
||||
// Fill in actual values from monthly data
|
||||
monthlyDataQuery.forEach((row) => {
|
||||
const category = categoriesMap.get(row.categoryId);
|
||||
if (category) {
|
||||
const periodIndex = periods.indexOf(row.period);
|
||||
if (periodIndex !== -1) {
|
||||
const monthLabel = monthLabels[periodIndex];
|
||||
category.data[monthLabel] = toNumber(row.totalAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fill in actual values from monthly data
|
||||
monthlyDataQuery.forEach((row) => {
|
||||
const category = categoriesMap.get(row.categoryId);
|
||||
if (category) {
|
||||
const periodIndex = periods.indexOf(row.period);
|
||||
if (periodIndex !== -1) {
|
||||
const monthLabel = monthLabels[periodIndex];
|
||||
category.data[monthLabel] = toNumber(row.totalAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to chart data format
|
||||
const chartData = monthLabels.map((month) => {
|
||||
const dataPoint: Record<string, number | string> = { month };
|
||||
// Convert to chart data format
|
||||
const chartData = monthLabels.map((month) => {
|
||||
const dataPoint: Record<string, number | string> = { month };
|
||||
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,163 +1,168 @@
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, orcamentos, 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 { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
|
||||
export type CategoryExpenseItem = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
};
|
||||
|
||||
export type ExpensesByCategoryData = {
|
||||
categories: CategoryExpenseItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
categories: CategoryExpenseItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
};
|
||||
|
||||
const calculatePercentageChange = (
|
||||
current: number,
|
||||
previous: number
|
||||
current: number,
|
||||
previous: number,
|
||||
): number | null => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
};
|
||||
|
||||
export async function fetchExpensesByCategory(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<ExpensesByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
// Busca despesas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
|
||||
// Busca despesas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.amount,
|
||||
);
|
||||
|
||||
// Busca despesas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
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(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
// Busca despesas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
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(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(toNumber(row.total));
|
||||
}
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(toNumber(row.total));
|
||||
}
|
||||
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(toNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(toNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
|
||||
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,161 +1,177 @@
|
||||
import { categorias, lancamentos, orcamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
orcamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||
import { safeToNumber } from "@/lib/utils/number";
|
||||
import { and, eq, isNull, or, sql, ne } from "drizzle-orm";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
|
||||
export type CategoryIncomeItem = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
};
|
||||
|
||||
export type IncomeByCategoryData = {
|
||||
categories: CategoryIncomeItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
categories: CategoryIncomeItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
};
|
||||
|
||||
export async function fetchIncomeByCategory(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<IncomeByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
// Busca receitas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
|
||||
// Busca receitas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.amount,
|
||||
);
|
||||
|
||||
// Busca receitas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
// Busca receitas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(safeToNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(safeToNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(safeToNumber(row.total));
|
||||
}
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(safeToNumber(row.total));
|
||||
}
|
||||
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(safeToNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(safeToNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
|
||||
const budgetAmount = row.budgetAmount ? safeToNumber(row.budgetAmount) : null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
const budgetAmount = row.budgetAmount
|
||||
? safeToNumber(row.budgetAmount)
|
||||
: null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user