mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor: atualiza transacoes dashboard e relatorios
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,7 +129,7 @@ export function CategoryReportFilters({
|
||||
|
||||
const selectedText =
|
||||
selectedCategories.length === 0
|
||||
? "Categoria"
|
||||
? "Category"
|
||||
: selectedCategories.length === categories.length
|
||||
? "Todas"
|
||||
: selectedCategories.length === 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user