refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,467 @@
import {
and,
eq,
gte,
ilike,
inArray,
lte,
ne,
not,
or,
sql,
sum,
} from "drizzle-orm";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { formatDateOnly } from "@/shared/utils/date";
import { safeToNumber } from "@/shared/utils/number";
import {
buildPeriodWindow,
formatCompactPeriodLabel,
getPreviousPeriod,
} from "@/shared/utils/period";
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;
};
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;
}[];
};
export type CartoesReportData = {
cards: CardSummary[];
totalLimit: number;
totalUsage: number;
totalUsagePercent: number;
selectedCard: CardDetailData | null;
};
type CardRow = {
id: string;
name: string;
brand: string | null;
logo: string | null;
limit: unknown;
status: string;
};
type CardUsageRow = {
cartaoId: string | null;
totalAmount: unknown;
};
type MonthlyUsageRow = {
period: string;
totalAmount: unknown;
};
type CategoryAmountRow = {
categoriaId: string | null;
totalAmount: unknown;
};
type CategoryInfoRow = {
id: string;
name: string;
icon: string | null;
};
type TopExpenseRow = {
id: string;
name: string;
amount: unknown;
purchaseDate: Date | string | null;
categoriaId: string | null;
};
type InvoiceStatusRow = {
period: string;
status: string | null;
};
export async function fetchCartoesReportData(
userId: string,
currentPeriod: string,
selectedCartaoId?: string | null,
): Promise<CartoesReportData> {
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"))),
)) as CardRow[];
if (allCards.length === 0) {
return {
cards: [],
totalLimit: 0,
totalUsage: 0,
totalUsagePercent: 0,
selectedCard: null,
};
}
const cardIds = allCards.map((c) => c.id);
// 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"),
})
.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),
or(
ne(lancamentos.condition, "Recorrente"),
sql`${lancamentos.purchaseDate} <= current_date`,
),
),
)
.groupBy(lancamentos.cartaoId)) as CardUsageRow[];
// 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)) 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)),
);
}
}
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;
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,
};
});
// 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;
// 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,
);
}
}
return {
cards,
totalLimit,
totalUsage,
totalUsagePercent,
selectedCard,
};
}
async function fetchCardDetail(
userId: string,
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
): Promise<CardDetailData> {
// Build period range for last 12 months
const periods = buildPeriodWindow(currentPeriod, 12);
const startPeriod = periods[0];
// 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)) as MonthlyUsageRow[];
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
return {
period,
periodLabel: formatCompactPeriodLabel(period),
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)) as CategoryAmountRow[];
// 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))) as CategoryInfoRow[])
: ([] as CategoryInfoRow[]);
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
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);
// 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)) as TopExpenseRow[];
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:
formatDateOnly(expense.purchaseDate, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "",
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)) as InvoiceStatusRow[];
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,
};
}

View File

@@ -0,0 +1,154 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos } 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 { safeToNumber as toNumber } from "@/shared/utils/number";
import { formatPeriodMonthShort } from "@/shared/utils/period";
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";
}>;
};
export async function fetchCategoryChartData(
userId: string,
startPeriod: string,
endPeriod: string,
categoryIds?: string[],
): Promise<CategoryChartData> {
const periods = generatePeriodRange(startPeriod, endPeriod);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
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")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
];
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.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)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
),
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 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",
}),
);
const categoryMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
dataByPeriod: Map<string, number>;
}
>();
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(),
});
}
categoryMap.get(categoryId)?.dataByPeriod.set(period, amount);
}
const chartData = periods.map((period) => {
const monthLabel = formatPeriodMonthShort(period).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
for (const category of categoryMap.values()) {
dataPoint[category.name] = category.dataByPeriod.get(period) ?? 0;
}
return dataPoint;
});
const months = periods.map((period) =>
formatPeriodMonthShort(period).toUpperCase(),
);
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 };
}

View File

@@ -0,0 +1,198 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos } 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 { safeToNumber as toNumber } from "@/shared/utils/number";
import type {
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
MonthlyData,
} from "./types";
import { calculatePercentageChange, generatePeriodRange } from "./utils";
/**
* Fetches category report data for multiple periods
*
* @param userId - User ID to filter data
* @param filters - Report filters (startPeriod, endPeriod, categoryIds)
* @returns Complete category report data
*/
export async function fetchCategoryReport(
userId: string,
filters: CategoryReportFilters,
): Promise<CategoryReportData> {
const { startPeriod, endPeriod, categoryIds } = filters;
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
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")),
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));
}
// 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(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>();
// 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;
// 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);
if (!categoryItem) continue;
// 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 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();
for (let i = 0; i < sortedPeriods.length; i++) {
const period = sortedPeriods[i];
const monthlyData = categoryItem.monthlyData.get(period);
if (!monthlyData) continue;
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;
}
}
}
// 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;
}
categoryItem.monthlyData.set(period, {
period,
amount: 0,
previousAmount,
percentageChange: calculatePercentageChange(0, previousAmount),
});
}
}
}
// 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;
});
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
grandTotal += categoryItem.total;
}
return {
categories,
periods,
totals: periodTotalsMap,
grandTotal,
};
}

View File

@@ -0,0 +1,12 @@
import { asc, eq } from "drizzle-orm";
import { type Categoria, categorias } 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)],
});
}

View File

@@ -0,0 +1,97 @@
"use client";
import { RiPieChartLine } from "@remixicon/react";
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
import type { CardDetailData } from "@/features/reports/cards-report-queries";
import MoneyValues from "@/shared/components/money-values";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Progress } from "@/shared/components/ui/progress";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
type CardCategoryBreakdownProps = {
data: CardDetailData["categoryBreakdown"];
};
export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
if (data.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria
</CardTitle>
</CardHeader>
<CardContent>
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma categoria encontrada"
description="Quando houver despesas categorizadas, elas aparecerão aqui."
/>
</CardContent>
</Card>
);
}
const _totalAmount = data.reduce((acc, c) => acc + c.amount, 0);
return (
<Card className="h-full overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria
</CardTitle>
</CardHeader>
<CardContent className="overflow-x-hidden pt-0">
<div className="flex flex-col">
{data.map((category, index) => (
<div
key={category.id}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={index}
/>
{/* Name and percentage */}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">
{category.name}
</span>
<span className="text-xs text-muted-foreground">
{category.percent.toFixed(0)}% do total
</span>
</div>
</div>
{/* Value */}
<div className="flex shrink-0 flex-col items-end">
<MoneyValues
className="text-foreground"
amount={category.amount}
/>
</div>
</div>
{/* Progress bar */}
<div className="pl-11 mt-1.5">
<Progress className="h-1.5" value={category.percent} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { RiCalendarCheckLine } from "@remixicon/react";
import type { CardDetailData } from "@/features/reports/cards-report-queries";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPeriodMonthShort } from "@/shared/utils/period";
type CardInvoiceStatusProps = {
data: CardDetailData["invoiceStatus"];
};
export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) {
const getStatusColor = (status: string | null) => {
switch (status) {
case "pago":
return "bg-success";
case "pendente":
return "bg-warning";
case "atrasado":
return "bg-destructive";
default:
return "bg-muted";
}
};
const getStatusLabel = (status: string | null) => {
switch (status) {
case "pago":
return "Pago";
case "pendente":
return "Pendente";
case "atrasado":
return "Atrasado";
default:
return "—";
}
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiCalendarCheckLine className="size-4 text-primary" />
Faturas
</CardTitle>
</CardHeader>
<CardContent>
<TooltipProvider>
<div className="flex items-center gap-1">
{data.map((invoice) => (
<Tooltip key={invoice.period}>
<TooltipTrigger asChild>
<div className="flex-1 flex flex-col items-center gap-2 cursor-default">
<div
className={cn(
"w-full h-2.5 rounded",
getStatusColor(invoice.status),
)}
/>
<span className="text-xs text-muted-foreground">
{formatPeriodMonthShort(invoice.period)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<p className="font-medium">
{formatCurrency(invoice.amount, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</p>
<p className="text-xs ">{getStatusLabel(invoice.status)}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { RiShoppingBag3Line } from "@remixicon/react";
import type { CardDetailData } from "@/features/reports/cards-report-queries";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Progress } from "@/shared/components/ui/progress";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
type CardTopExpensesProps = {
data: CardDetailData["topExpenses"];
};
export function CardTopExpenses({ data }: CardTopExpensesProps) {
if (data.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiShoppingBag3Line className="size-4 text-primary" />
Top 10 Gastos do Mês
</CardTitle>
</CardHeader>
<CardContent>
<WidgetEmptyState
icon={
<RiShoppingBag3Line className="size-6 text-muted-foreground" />
}
title="Nenhum gasto encontrado"
description="Quando houver gastos registrados, eles aparecerão aqui."
/>
</CardContent>
</Card>
);
}
const maxAmount = Math.max(...data.map((e) => e.amount));
return (
<Card className="h-full overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiShoppingBag3Line className="size-4 text-primary" />
Top 10 Gastos do Mês
</CardTitle>
</CardHeader>
<CardContent className="overflow-x-hidden pt-0">
<div className="flex flex-col">
{data.map((expense, index) => (
<div
key={expense.id}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Rank number */}
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted">
<span className="text-sm font-semibold text-muted-foreground">
{index + 1}
</span>
</div>
{/* Name and details */}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">
{expense.name}
</span>
<div className="mt-0.5 flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
{expense.date}
</span>
{expense.category && (
<Badge
variant="secondary"
className="h-5 max-w-full px-1.5 py-0 text-xs truncate"
>
{expense.category}
</Badge>
)}
</div>
</div>
</div>
{/* Value */}
<div className="flex shrink-0 flex-col items-end">
<MoneyValues
className="text-foreground"
amount={expense.amount}
/>
</div>
</div>
{/* Progress bar */}
<div className="pl-12 mt-1.5">
<Progress
className="h-1.5"
value={(expense.amount / maxAmount) * 100}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { RiBankCard2Line, RiBarChartBoxLine } from "@remixicon/react";
import Image from "next/image";
import {
Bar,
BarChart,
CartesianGrid,
ReferenceLine,
XAxis,
YAxis,
} from "recharts";
import type { CardDetailData } from "@/features/reports/cards-report-queries";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/shared/components/ui/chart";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency, formatCurrencyCompact } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage";
type CardUsageChartProps = {
data: CardDetailData["monthlyUsage"];
limit: number;
card: {
name: string;
logo: string | null;
};
};
const chartConfig = {
amount: {
label: "Uso",
color: "#3b82f6",
},
} satisfies ChartConfig;
export function CardUsageChart({ data, limit, card }: CardUsageChartProps) {
// Always show last 12 months
const chartData = data.slice(-12).map((item) => ({
month: item.periodLabel,
amount: item.amount,
}));
const logoPath = resolveLogoSrc(card.logo);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiBarChartBoxLine className="size-4 text-primary" />
Histórico de Uso
</CardTitle>
{/* Card logo and name */}
<div className="flex min-w-0 items-center gap-2">
{logoPath ? (
<Image
src={logoPath}
alt={card.name}
width={24}
height={24}
className="rounded-full object-contain"
/>
) : (
<RiBankCard2Line className="size-5 text-muted-foreground" />
)}
<span className="max-w-24 truncate text-sm font-medium text-muted-foreground sm:max-w-none">
{card.name}
</span>
</div>
</div>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart
data={chartData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={(value) =>
Math.abs(Number(value)) >= 1000
? formatCurrencyCompact(Number(value), {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})
: formatCurrency(Number(value), {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})
}
/>
{limit > 0 && (
<ReferenceLine
y={limit}
stroke="#ef4444"
strokeDasharray="3 3"
label={{
value: "Limite",
position: "right",
className: "text-xs fill-destructive",
}}
/>
)}
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const data = payload[0].payload;
const value = data.amount as number;
const usagePercent = limit > 0 ? (value / limit) * 100 : 0;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{data.month}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-4">
<span className="text-xs text-muted-foreground">
Uso
</span>
<span className="text-xs font-medium tabular-nums">
{formatCurrency(value, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</span>
</div>
{limit > 0 && (
<div className="flex items-center justify-between gap-4">
<span className="text-xs text-muted-foreground">
% do Limite
</span>
<span className="text-xs font-medium tabular-nums">
{formatPercentage(usagePercent, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</span>
</div>
)}
</div>
</div>
);
}}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
/>
<Bar
dataKey="amount"
fill="var(--primary)"
radius={[4, 4, 0, 0]}
maxBarSize={50}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { RiBankCard2Line } from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import type { CartoesReportData } from "@/features/reports/cards-report-queries";
import MoneyValues from "@/shared/components/money-values";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Progress } from "@/shared/components/ui/progress";
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { cn } from "@/shared/utils";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage";
type CardsOverviewProps = {
data: CartoesReportData;
};
export function CardsOverview({ data }: CardsOverviewProps) {
const searchParams = useSearchParams();
const periodoParam = searchParams.get("periodo");
const getUsageColor = (percent: number) => {
if (percent < 50) return "bg-success";
if (percent < 80) return "bg-warning";
return "bg-destructive";
};
const buildUrl = (cardId: string) => {
const params = new URLSearchParams();
if (periodoParam) params.set("periodo", periodoParam);
params.set("cartao", cardId);
return `/reports/card-usage?${params.toString()}`;
};
const summaryCards = [
{ title: "Limite", value: data.totalLimit, isMoney: true },
{ title: "Usado", value: data.totalUsage, isMoney: true },
{
title: "Disponível",
value: data.totalLimit - data.totalUsage,
isMoney: true,
},
{ title: "Utilização", value: data.totalUsagePercent, isMoney: false },
];
if (data.cards.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<RiBankCard2Line className="size-8 mb-2" />
<p className="text-sm">Nenhum cartão encontrado</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{/* Summary stats */}
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
{summaryCards.map((card) => (
<Card key={card.title}>
<CardContent className="px-4">
<p className="text-xs text-muted-foreground">{card.title}</p>
{card.isMoney ? (
<MoneyValues
className="text-2xl font-semibold"
amount={card.value}
/>
) : (
<p className="text-2xl font-semibold">
{formatPercentage(card.value, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</p>
)}
</CardContent>
</Card>
))}
</div>
<p className="text-base font-bold ml-2 py-2">Meus cartões</p>
{/* Cards list */}
<div className="grid gap-2 grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
{data.cards.map((card) => {
const logoPath = resolveLogoSrc(card.logo);
const brandAsset = resolveCardBrandAsset(card.brand);
const isSelected = data.selectedCard?.card.id === card.id;
return (
<Card
key={card.id}
className={cn("px-1 py-1", isSelected && "ring-1 ring-primary")}
>
<Link
href={buildUrl(card.id)}
className={cn("flex items-center gap-3 p-3")}
>
<div className="flex size-9 shrink-0 items-center justify-center">
{logoPath ? (
<Image
src={logoPath}
alt={card.name}
width={32}
height={32}
className="rounded-full object-contain"
/>
) : (
<RiBankCard2Line className="size-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-base font-bold truncate">
{card.name}
</span>
{brandAsset && (
<Image
src={brandAsset}
alt={card.brand || ""}
width={18}
height={12}
className="h-2.5 w-auto shrink-0 opacity-70"
/>
)}
</div>
<p className="text-xs text-muted-foreground tabular-nums">
{formatCurrency(card.currentUsage)} /{" "}
{formatCurrency(card.limit)}
</p>
<div className="flex items-center gap-2">
<Progress
value={Math.min(card.usagePercent, 100)}
className={cn(
"h-2 flex-1",
`[&>div]:${getUsageColor(card.usagePercent)}`,
)}
/>
<span className="text-xs font-medium tabular-nums">
{formatPercentage(card.usagePercent, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</span>
</div>
</div>
</Link>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
import { formatPercentageChange } from "@/features/reports/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatCurrency } from "@/shared/utils/currency";
import { cn } from "@/shared/utils/ui";
interface CategoryCellProps {
value: number;
previousValue: number;
categoryType: "despesa" | "receita";
isFirstMonth: boolean;
}
export function CategoryCell({
value,
previousValue,
categoryType,
isFirstMonth,
}: CategoryCellProps) {
const percentageChange =
!isFirstMonth && previousValue !== 0
? ((value - previousValue) / previousValue) * 100
: null;
const absoluteChange = !isFirstMonth ? value - previousValue : null;
const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0;
// Despesa: aumento é ruim (vermelho), diminuição é bom (verde)
// Receita: aumento é bom (verde), diminuição é ruim (vermelho)
const isPositive = categoryType === "receita" ? isIncrease : isDecrease;
const isNegative = categoryType === "receita" ? isDecrease : isIncrease;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2">
<span className="font-medium">{formatCurrency(value)}</span>
{!isFirstMonth && percentageChange !== null && (
<div
className={cn(
"flex items-center gap-0.5 text-xs",
isNegative && "text-destructive",
isPositive && "text-success",
)}
>
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
{isDecrease && <RiArrowDownSFill className="h-3 w-3" />}
<span>{formatPercentageChange(percentageChange)}</span>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="flex flex-col gap-1">
<div className="font-medium">{formatCurrency(value)}</div>
{!isFirstMonth && absoluteChange !== null && (
<>
<div className="font-bold">
Mês anterior: {formatCurrency(previousValue)}
</div>
<div
className={cn(
"font-medium",
isNegative && "text-destructive",
isPositive && "text-success",
)}
>
Diferença:{" "}
{absoluteChange >= 0
? `+${formatCurrency(absoluteChange)}`
: formatCurrency(absoluteChange)}
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
import { formatPeriodLabel } from "@/features/reports/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import type {
CategoryReportData,
CategoryReportItem,
} from "@/shared/lib/types/reports";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { CategoryCell } from "./category-cell";
interface CategoryReportCardsProps {
data: CategoryReportData;
}
interface CategoryCardProps {
category: CategoryReportItem;
periods: string[];
periodCount: number;
colorIndex: number;
}
function CategoryCard({
category,
periods,
periodCount,
colorIndex,
}: CategoryCardProps) {
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
const averageMonthlyTotal = category.total / periodCount;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={colorIndex}
/>
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex-1 truncate hover:underline underline-offset-2"
>
{category.name}
</Link>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{periods.map((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const isFirstMonth = periodIndex === 0;
return (
<div
key={period}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<span className="text-sm text-muted-foreground">
{formatPeriodLabel(period)}
</span>
<CategoryCell
value={monthData?.amount ?? 0}
previousValue={monthData?.previousAmount ?? 0}
categoryType={category.type}
isFirstMonth={isFirstMonth}
/>
</div>
);
})}
<div className="flex items-center justify-between font-semibold text-info">
<span>Média mensal</span>
<span>{formatCurrency(averageMonthlyTotal)}</span>
</div>
<div className="flex items-center justify-between pt-2 font-semibold">
<span>Total</span>
<span>{formatCurrency(category.total)}</span>
</div>
</CardContent>
</Card>
);
}
interface SectionProps {
title: string;
categories: CategoryReportItem[];
periods: string[];
periodCount: number;
colorIndexOffset: number;
total: number;
}
function Section({
title,
categories,
periods,
periodCount,
colorIndexOffset,
total,
}: SectionProps) {
if (categories.length === 0) {
return null;
}
const averageMonthlyTotal = total / periodCount;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</span>
<div className="flex flex-col items-end">
<span className="text-sm text-muted-foreground">
{formatCurrency(total)}
</span>
<span className="text-xs font-semibold text-info">
Média: {formatCurrency(averageMonthlyTotal)}
</span>
</div>
</div>
{categories.map((category, index) => (
<CategoryCard
key={category.categoryId}
category={category}
periods={periods}
periodCount={periodCount}
colorIndex={colorIndexOffset + index}
/>
))}
</div>
);
}
export function CategoryReportCards({ data }: CategoryReportCardsProps) {
const { categories, periods } = data;
const periodCount = Math.max(periods.length, 1);
// Separate categories by type and calculate totals
const { receitas, despesas, receitasTotal, despesasTotal } = useMemo(() => {
const receitas: CategoryReportItem[] = [];
const despesas: CategoryReportItem[] = [];
let receitasTotal = 0;
let despesasTotal = 0;
for (const category of categories) {
if (category.type === "receita") {
receitas.push(category);
receitasTotal += category.total;
} else {
despesas.push(category);
despesasTotal += category.total;
}
}
return { receitas, despesas, receitasTotal, despesasTotal };
}, [categories]);
return (
<div className="md:hidden space-y-6">
{/* Despesas Section */}
<Section
title="Despesas"
categories={despesas}
periods={periods}
periodCount={periodCount}
colorIndexOffset={0}
total={despesasTotal}
/>
{/* Receitas Section */}
<Section
title="Receitas"
categories={receitas}
periods={periods}
periodCount={periodCount}
colorIndexOffset={despesas.length}
total={receitasTotal}
/>
</div>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
import { RiPieChartLine } from "@remixicon/react";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
type TooltipProps,
XAxis,
} from "recharts";
import type { CategoryChartData } from "@/features/reports/category-chart-queries";
import { EmptyState } from "@/shared/components/empty-state";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/shared/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
import { currencyFormatter } from "@/shared/utils/currency";
function AreaTooltip({ active, payload, label }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null;
const items = payload
.filter((entry) => Number(entry.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
if (items.length === 0) return null;
return (
<div className="min-w-[210px] rounded-lg border border-border/50 bg-background px-3 py-2.5 shadow-xl">
<p className="mb-2.5 border-b border-border/50 pb-1.5 text-xs font-semibold text-foreground">
{label}
</p>
<div className="space-y-1.5">
{items.map((entry) => (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-6"
>
<div className="flex min-w-0 items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
{currencyFormatter.format(Number(entry.value))}
</span>
</div>
))}
</div>
</div>
);
}
interface CategoryReportChartProps {
data: CategoryChartData;
}
const LIMIT_OPTIONS = [
{ value: "5", label: "Top 5" },
{ value: "10", label: "Top 10" },
{ value: "15", label: "Top 15" },
] as const;
const MAX_CATEGORIES = 15;
export function CategoryReportChart({ data }: CategoryReportChartProps) {
const { chartData, categories } = data;
const [limit, setLimit] = React.useState("10");
const { topCategories, filteredChartData } = React.useMemo(() => {
const limitNum = Math.min(Number(limit), MAX_CATEGORIES);
const categoriesWithTotal = categories.map((category) => ({
...category,
total: chartData.reduce((sum, point) => {
const v = point[category.name];
return sum + (typeof v === "number" ? v : 0);
}, 0),
}));
const sorted = categoriesWithTotal
.sort((a, b) => b.total - a.total)
.slice(0, limitNum);
const filtered = chartData.map((point) => {
const result: { month: string; [key: string]: number | string } = {
month: point.month,
};
for (const cat of sorted) {
result[cat.name] = (point[cat.name] as number) ?? 0;
}
return result;
});
return { topCategories: sorted, filteredChartData: filtered };
}, [categories, chartData, limit]);
const chartConfig = React.useMemo<ChartConfig>(() => {
const config: ChartConfig = {};
for (let i = 0; i < topCategories.length; i++) {
const cat = topCategories[i];
config[cat.name] = {
label: cat.name,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
};
}
return config;
}, [topCategories]);
if (categories.length === 0 || chartData.length === 0) {
return (
<EmptyState
title="Nenhum dado disponível"
description="Não há transações no período selecionado para as categorias filtradas."
media={<RiPieChartLine className="h-12 w-12" />}
mediaVariant="icon"
/>
);
}
const firstMonth = chartData[0]?.month ?? "";
const lastMonth = chartData[chartData.length - 1]?.month ?? "";
const periodLabel =
firstMonth === lastMonth ? firstMonth : `${firstMonth} ${lastMonth}`;
return (
<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>
<CardDescription>{periodLabel}</CardDescription>
</div>
<Select value={limit} onValueChange={setLimit}>
<SelectTrigger
className="hidden w-[130px] rounded-lg sm:ml-auto sm:flex"
aria-label="Número de categorias"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
{LIMIT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="rounded-lg"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[300px] w-full"
>
<AreaChart data={filteredChartData}>
<defs>
{topCategories.map((cat, index) => {
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
return (
<linearGradient
key={cat.id}
id={`fill-${cat.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
</linearGradient>
);
})}
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip cursor={false} content={<AreaTooltip />} />
{topCategories.map((cat, index) => (
<Area
key={cat.id}
dataKey={cat.name}
type="natural"
fill={`url(#fill-${cat.id})`}
stroke={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
strokeWidth={1.5}
stackId="a"
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,387 @@
"use client";
import {
RiDownloadLine,
RiFileExcelLine,
RiFilePdfLine,
RiFileTextLine,
} from "@remixicon/react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { useState } from "react";
import { toast } from "sonner";
import * as XLSX from "xlsx";
import {
formatPercentageChange,
formatPeriodLabel,
} from "@/features/reports/utils";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import type { CategoryReportData } from "@/shared/lib/types/reports";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateTime } from "@/shared/utils/date";
import {
getPrimaryPdfColor,
loadExportLogoDataUrl,
} from "@/shared/utils/export-branding";
import type { FilterState } from "./types";
interface CategoryReportExportProps {
data: CategoryReportData;
filters: FilterState;
}
export function CategoryReportExport({
data,
filters,
}: CategoryReportExportProps) {
const [isExporting, setIsExporting] = useState(false);
const getFileName = (extension: string) => {
const start = filters.startPeriod;
const end = filters.endPeriod;
return `relatorio-categorias-${start}-${end}.${extension}`;
};
const exportToCSV = () => {
try {
setIsExporting(true);
// Build CSV content
const headers = [
"Categoria",
...data.periods.map(formatPeriodLabel),
"Total",
];
const rows: string[][] = [];
// Add category rows
data.categories.forEach((category) => {
const row: string[] = [category.name];
data.periods.forEach((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const value = monthData?.amount ?? 0;
const percentageChange = monthData?.percentageChange;
const isFirstMonth = periodIndex === 0;
let cellValue = formatCurrency(value);
// Add indicator as text
if (!isFirstMonth && percentageChange != null) {
const arrow = percentageChange > 0 ? "↑" : "↓";
cellValue += ` (${arrow}${formatPercentageChange(
percentageChange,
)})`;
}
row.push(cellValue);
});
row.push(formatCurrency(category.total));
rows.push(row);
});
// Add totals row
const totalsRow = ["Total Geral"];
data.periods.forEach((period) => {
totalsRow.push(formatCurrency(data.totals.get(period) ?? 0));
});
totalsRow.push(formatCurrency(data.grandTotal));
rows.push(totalsRow);
// Generate CSV string
const csvContent = [
headers.join(","),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")),
].join("\n");
// Create blob and download
const blob = new Blob([`\uFEFF${csvContent}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = getFileName("csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Relatório exportado em CSV com sucesso!");
} catch (error) {
console.error("Error exporting to CSV:", error);
toast.error("Erro ao exportar relatório em CSV");
} finally {
setIsExporting(false);
}
};
const exportToExcel = () => {
try {
setIsExporting(true);
// Build data array
const headers = [
"Categoria",
...data.periods.map(formatPeriodLabel),
"Total",
];
const rows: (string | number)[][] = [];
// Add category rows
data.categories.forEach((category) => {
const row: (string | number)[] = [category.name];
data.periods.forEach((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const value = monthData?.amount ?? 0;
const percentageChange = monthData?.percentageChange;
const isFirstMonth = periodIndex === 0;
let cellValue: string = formatCurrency(value);
// Add indicator as text
if (!isFirstMonth && percentageChange != null) {
const arrow = percentageChange > 0 ? "↑" : "↓";
cellValue += ` (${arrow}${formatPercentageChange(
percentageChange,
)})`;
}
row.push(cellValue);
});
row.push(formatCurrency(category.total));
rows.push(row);
});
// Add totals row
const totalsRow: (string | number)[] = ["Total Geral"];
data.periods.forEach((period) => {
totalsRow.push(formatCurrency(data.totals.get(period) ?? 0));
});
totalsRow.push(formatCurrency(data.grandTotal));
rows.push(totalsRow);
// Create worksheet
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
// Set column widths
ws["!cols"] = [
{ wch: 20 }, // Categoria
...data.periods.map(() => ({ wch: 15 })), // Periods
{ wch: 15 }, // Total
];
// Create workbook and download
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias");
XLSX.writeFile(wb, getFileName("xlsx"));
toast.success("Relatório exportado em Excel com sucesso!");
} catch (error) {
console.error("Error exporting to Excel:", error);
toast.error("Erro ao exportar relatório em Excel");
} finally {
setIsExporting(false);
}
};
const exportToPDF = async () => {
try {
setIsExporting(true);
// Create PDF
const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor();
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
loadExportLogoDataUrl("/images/logo_small.png"),
loadExportLogoDataUrl("/images/logo_text.png"),
]);
let brandingEndX = 14;
if (smallLogoDataUrl) {
doc.addImage(smallLogoDataUrl, "PNG", brandingEndX, 7.5, 8, 8);
brandingEndX += 10;
}
if (textLogoDataUrl) {
doc.addImage(textLogoDataUrl, "PNG", brandingEndX, 8, 30, 8);
brandingEndX += 32;
}
const titleX = brandingEndX > 14 ? brandingEndX + 4 : 14;
// Add header
doc.setFont("courier", "normal");
doc.setFontSize(16);
doc.text("Relatório de Categorias por Período", titleX, 15);
doc.setFontSize(10);
doc.text(
`Período: ${formatPeriodLabel(
filters.startPeriod,
)} - ${formatPeriodLabel(filters.endPeriod)}`,
titleX,
22,
);
doc.text(
`Gerado em: ${
formatDateTime(new Date(), {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"
}`,
titleX,
27,
);
doc.setDrawColor(...primaryColor);
doc.setLineWidth(0.5);
doc.line(14, 31, doc.internal.pageSize.getWidth() - 14, 31);
// Build table data
const headers = [
["Categoria", ...data.periods.map(formatPeriodLabel), "Total"],
];
const body: string[][] = [];
// Add category rows
data.categories.forEach((category) => {
const row: string[] = [category.name];
data.periods.forEach((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const value = monthData?.amount ?? 0;
const percentageChange = monthData?.percentageChange;
const isFirstMonth = periodIndex === 0;
let cellValue = formatCurrency(value);
// Add indicator as text
if (!isFirstMonth && percentageChange != null) {
const arrow = percentageChange > 0 ? "↑" : "↓";
cellValue += `\n(${arrow}${formatPercentageChange(
percentageChange,
)})`;
}
row.push(cellValue);
});
row.push(formatCurrency(category.total));
body.push(row);
});
// Add totals row
const totalsRow = ["Total Geral"];
data.periods.forEach((period) => {
totalsRow.push(formatCurrency(data.totals.get(period) ?? 0));
});
totalsRow.push(formatCurrency(data.grandTotal));
body.push(totalsRow);
// Generate table with autoTable
autoTable(doc, {
head: headers,
body: body,
startY: 35,
tableWidth: "auto",
styles: {
font: "courier",
fontSize: 8,
cellPadding: 2,
},
headStyles: {
fillColor: primaryColor,
textColor: 255,
fontStyle: "bold",
},
footStyles: {
fillColor: [229, 231, 235], // Gray
textColor: 0,
fontStyle: "bold",
},
columnStyles: {
0: { cellWidth: 35 }, // Categoria column wider
},
didParseCell: (cellData) => {
// Style totals row
if (
cellData.row.index === body.length - 1 &&
cellData.section === "body"
) {
cellData.cell.styles.fillColor = [243, 244, 246];
cellData.cell.styles.fontStyle = "bold";
}
// Color coding for category rows (despesa/receita)
if (
cellData.section === "body" &&
cellData.row.index < body.length - 1
) {
const categoryIndex = cellData.row.index;
const category = data.categories[categoryIndex];
if (category && cellData.column.index > 0) {
// Apply subtle background colors
if (category.type === "despesa") {
cellData.cell.styles.textColor = [220, 38, 38]; // Red text
} else if (category.type === "receita") {
cellData.cell.styles.textColor = [22, 163, 74]; // Green text
}
}
}
},
margin: { top: 35 },
});
// Save PDF
doc.save(getFileName("pdf"));
toast.success("Relatório exportado em PDF com sucesso!");
} catch (error) {
console.error("Error exporting to PDF:", error);
toast.error("Erro ao exportar relatório em PDF");
} finally {
setIsExporting(false);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="text-sm border-dashed"
disabled={isExporting || data.categories.length === 0}
aria-label="Exportar relatório de categorias"
>
<RiDownloadLine className="mr-2 h-4 w-4" aria-hidden="true" />
{isExporting ? "Exportando..." : "Exportar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportToCSV} disabled={isExporting}>
<RiFileTextLine className="mr-2 h-4 w-4" aria-hidden="true" />
Exportar como CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToExcel} disabled={isExporting}>
<RiFileExcelLine className="mr-2 h-4 w-4" aria-hidden="true" />
Exportar como Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToPDF} disabled={isExporting}>
<RiFilePdfLine className="mr-2 h-4 w-4" aria-hidden="true" />
Exportar como PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,294 @@
"use client";
import {
RiCalendarLine,
RiCheckLine,
RiExpandUpDownLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { validateDateRange } from "@/features/reports/utils";
import { Button } from "@/shared/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/components/ui/command";
import { MonthPicker } from "@/shared/components/ui/month-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { getIconComponent } from "@/shared/utils/icons";
import {
addMonthsToPeriod,
dateToPeriod,
formatShortPeriodLabel,
getCurrentPeriod,
periodToDate,
} from "@/shared/utils/period";
import type { CategoryReportFiltersProps } from "./types";
/**
* Category Report Filters Component
* Provides filters for categories selection and date range
*/
export function CategoryReportFilters({
categories,
filters,
onFiltersChange,
isLoading = false,
exportButton,
}: CategoryReportFiltersProps & { exportButton?: ReactNode }) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [startMonthOpen, setStartMonthOpen] = useState(false);
const [endMonthOpen, setEndMonthOpen] = useState(false);
// Filter categories by search
const filteredCategories = useMemo(() => {
if (!searchValue) return categories;
const search = searchValue.toLowerCase();
return categories.filter((cat) => cat.name.toLowerCase().includes(search));
}, [categories, searchValue]);
// Get selected categories for display
const selectedCategories = useMemo(() => {
if (filters.selectedCategories.length === 0) return [];
return categories.filter((cat) =>
filters.selectedCategories.includes(cat.id),
);
}, [categories, filters.selectedCategories]);
// Handle category toggle
const handleCategoryToggle = (categoryId: string) => {
const newSelected = filters.selectedCategories.includes(categoryId)
? filters.selectedCategories.filter((id) => id !== categoryId)
: [...filters.selectedCategories, categoryId];
onFiltersChange({
...filters,
selectedCategories: newSelected,
});
};
// Handle select all
const handleSelectAll = () => {
onFiltersChange({
...filters,
selectedCategories: categories.map((cat) => cat.id),
});
setOpen(false);
};
// Handle clear all
const handleClearAll = () => {
onFiltersChange({
...filters,
selectedCategories: [],
});
setOpen(false);
};
// Handle date change from MonthPicker
const handleDateChange = (field: "startPeriod" | "endPeriod", date: Date) => {
const period = dateToPeriod(date);
onFiltersChange({
...filters,
[field]: period,
});
// Close the popover after selection
if (field === "startPeriod") {
setStartMonthOpen(false);
} else {
setEndMonthOpen(false);
}
};
// Handle reset all filters
const handleReset = () => {
const currentPeriod = getCurrentPeriod();
const startPeriod = addMonthsToPeriod(currentPeriod, -5);
onFiltersChange({
selectedCategories: [],
startPeriod,
endPeriod: currentPeriod,
});
};
const validation =
!filters.startPeriod || !filters.endPeriod
? { isValid: true }
: validateDateRange(filters.startPeriod, filters.endPeriod);
const selectedText =
selectedCategories.length === 0
? "Categoria"
: selectedCategories.length === categories.length
? "Todas"
: selectedCategories.length === 1
? selectedCategories[0].name
: `${selectedCategories.length} selecionadas`;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-between">
<div className="flex w-full flex-wrap items-center justify-center gap-2 md:w-auto md:justify-start">
{/* Category Multi-Select */}
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label="Selecionar categorias para filtrar"
className="w-full md:w-[180px] justify-between text-sm border-dashed border-input"
disabled={isLoading}
>
<span className="truncate">{selectedText}</span>
<RiExpandUpDownLine
className="ml-2 h-4 w-4 shrink-0 opacity-50"
aria-hidden="true"
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput
placeholder="Buscar categoria..."
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
<CommandGroup>
{/* Select All / Clear All */}
<div className="flex gap-1 p-2 border-b">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs flex-1"
onClick={handleSelectAll}
>
Todas
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs flex-1"
onClick={handleClearAll}
>
Limpar
</Button>
</div>
{/* Category List */}
{filteredCategories.map((category) => {
const isSelected = filters.selectedCategories.includes(
category.id,
);
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.id}
onSelect={() => handleCategoryToggle(category.id)}
className="cursor-pointer"
>
<div className="flex items-center gap-2 flex-1">
{IconComponent && (
<IconComponent
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<span className="truncate">{category.name}</span>
</div>
{isSelected && (
<RiCheckLine
className="ml-auto h-4 w-4"
aria-hidden="true"
/>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Start Period Picker */}
<Popover open={startMonthOpen} onOpenChange={setStartMonthOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[calc(50%-0.25rem)] md:w-[150px] justify-start text-sm border-dashed"
disabled={isLoading}
>
<RiCalendarLine className="mr-2 h-4 w-4" />
{formatShortPeriodLabel(filters.startPeriod)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={periodToDate(filters.startPeriod)}
onMonthSelect={(date) => handleDateChange("startPeriod", date)}
/>
</PopoverContent>
</Popover>
{/* End Period Picker */}
<Popover open={endMonthOpen} onOpenChange={setEndMonthOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[calc(50%-0.25rem)] md:w-[150px] justify-start text-sm border-dashed"
disabled={isLoading}
>
<RiCalendarLine className="mr-2 h-4 w-4" />
{formatShortPeriodLabel(filters.endPeriod)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={periodToDate(filters.endPeriod)}
onMonthSelect={(date) => handleDateChange("endPeriod", date)}
/>
</PopoverContent>
</Popover>
{/* Reset Button */}
<Button
type="button"
variant="link"
size="sm"
onClick={handleReset}
disabled={isLoading}
className="w-full text-center md:w-auto md:text-left"
>
Limpar
</Button>
</div>
{/* Export Button */}
<div className="w-full md:w-auto">{exportButton}</div>
</div>
{/* Validation Message */}
{!validation.isValid && validation.error && (
<div className="text-sm text-destructive">{validation.error}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
import {
RiFilter3Line,
RiLineChartLine,
RiPieChartLine,
RiTable2,
} from "@remixicon/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import type { CategoryChartData } from "@/features/reports/category-chart-queries";
import { EmptyState } from "@/shared/components/empty-state";
import { CategoryReportSkeleton } from "@/shared/components/skeletons/category-report-skeleton";
import { Card } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import type { CategoryReportData } from "@/shared/lib/types/reports";
import { CategoryReportCards } from "./category-report-cards";
import { CategoryReportChart } from "./category-report-chart";
import { CategoryReportExport } from "./category-report-export";
import { CategoryReportFilters } from "./category-report-filters";
import { CategoryReportTable } from "./category-report-table";
import type { CategoryOption, FilterState } from "./types";
interface CategoryReportPageProps {
initialData: CategoryReportData;
categories: CategoryOption[];
initialFilters: FilterState;
chartData: CategoryChartData;
}
export function CategoryReportPage({
initialData,
categories,
initialFilters,
chartData,
}: CategoryReportPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [filters, setFilters] = useState<FilterState>(initialFilters);
// Get active tab from URL or default to "table"
const activeTab = searchParams.get("aba") || "table";
// Debounce timer
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const handleFiltersChange = (newFilters: FilterState) => {
setFilters(newFilters);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
startTransition(() => {
const params = new URLSearchParams(searchParams.toString());
params.set("inicio", newFilters.startPeriod);
params.set("fim", newFilters.endPeriod);
if (newFilters.selectedCategories.length > 0) {
params.set("categorias", newFilters.selectedCategories.join(","));
} else {
params.delete("categorias");
}
const currentTab = searchParams.get("aba");
if (currentTab) {
params.set("aba", currentTab);
}
router.push(`?${params.toString()}`, { scroll: false });
});
}, 300);
};
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("aba", value);
router.push(`?${params.toString()}`, { scroll: false });
};
// Check if no categories are available
const hasNoCategories = categories.length === 0;
// Check if no data in period
const hasNoData = initialData.categories.length === 0 && !hasNoCategories;
return (
<div className="flex flex-col gap-6">
{/* Filters */}
<CategoryReportFilters
categories={categories}
filters={filters}
onFiltersChange={handleFiltersChange}
exportButton={
<CategoryReportExport data={initialData} filters={filters} />
}
/>
{/* Loading State */}
{isPending && <CategoryReportSkeleton />}
{/* Empty States */}
{!isPending && hasNoCategories && (
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
title="Nenhuma categoria cadastrada"
description="Você precisa cadastrar categorias antes de visualizar o relatório."
media={<RiPieChartLine className="size-6 text-primary" />}
/>
</Card>
)}
{!isPending &&
!hasNoCategories &&
hasNoData &&
filters.selectedCategories.length === 0 && (
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
title="Selecione pelo menos uma categoria"
description="Use o filtro acima para selecionar as categorias que deseja visualizar no relatório."
media={<RiFilter3Line className="size-6 text-primary" />}
/>
</Card>
)}
{!isPending &&
!hasNoCategories &&
hasNoData &&
filters.selectedCategories.length > 0 && (
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
title="Nenhum lançamento encontrado"
description="Não há transações no período selecionado para as categorias filtradas."
media={<RiPieChartLine className="size-6 text-primary" />}
/>
</Card>
)}
{/* Tabs: Table and Chart */}
{!isPending && !hasNoCategories && !hasNoData && (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList>
<TabsTrigger value="table">
<RiTable2 className="h-4 w-4 mr-2" />
Tabela
</TabsTrigger>
<TabsTrigger value="chart">
<RiLineChartLine className="h-4 w-4 mr-2" />
Gráfico
</TabsTrigger>
</TabsList>
<TabsContent value="table" className="mt-4">
{/* Desktop Table */}
<div className="hidden md:block">
<CategoryReportTable data={initialData} />
</div>
{/* Mobile Cards */}
<CategoryReportCards data={initialData} />
</TabsContent>
<TabsContent value="chart" className="mt-4">
<CategoryReportChart data={chartData} />
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useMemo } from "react";
import type {
CategoryReportData,
CategoryReportItem,
} from "@/shared/lib/types/reports";
import { CategoryTable } from "./category-table";
interface CategoryReportTableProps {
data: CategoryReportData;
}
export function CategoryReportTable({ data }: CategoryReportTableProps) {
const { categories, periods } = data;
// Separate categories by type
const { receitas, despesas } = useMemo(() => {
const receitas: CategoryReportItem[] = [];
const despesas: CategoryReportItem[] = [];
for (const category of categories) {
if (category.type === "receita") {
receitas.push(category);
} else {
despesas.push(category);
}
}
return { receitas, despesas };
}, [categories]);
return (
<div className="flex flex-col gap-6">
{/* Despesas Table */}
<CategoryTable
title="Despesas"
categories={despesas}
periods={periods}
colorIndexOffset={0}
/>
{/* Receitas Table */}
<CategoryTable
title="Receitas"
categories={receitas}
periods={periods}
colorIndexOffset={despesas.length}
/>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
import { formatPeriodLabel } from "@/features/reports/utils";
import StatusDot from "@/shared/components/status-dot";
import { Card } from "@/shared/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import type { CategoryReportItem } from "@/shared/lib/types/reports";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { CategoryCell } from "./category-cell";
export interface CategoryTableProps {
title: string;
categories: CategoryReportItem[];
periods: string[];
colorIndexOffset: number;
}
export function CategoryTable({
title,
categories,
periods,
colorIndexOffset,
}: CategoryTableProps) {
// Calculate section totals
const sectionTotals = useMemo(() => {
const totalsMap = new Map<string, number>();
let grandTotal = 0;
const periodCount = Math.max(periods.length, 1);
for (const category of categories) {
grandTotal += category.total;
for (const period of periods) {
const monthData = category.monthlyData.get(period);
const current = totalsMap.get(period) ?? 0;
totalsMap.set(period, current + (monthData?.amount ?? 0));
}
}
return {
totalsMap,
grandTotal,
averageMonthlyTotal: grandTotal / periodCount,
};
}, [categories, periods]);
if (categories.length === 0) {
return null;
}
return (
<Card className="px-6 py-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[240px] min-w-[240px] font-bold">
Categoria
</TableHead>
{periods.map((period) => (
<TableHead
key={period}
className="text-right min-w-[120px] font-bold"
>
{formatPeriodLabel(period)}
</TableHead>
))}
<TableHead className="text-right min-w-[140px] font-bold">
Média
</TableHead>
<TableHead className="text-right min-w-[120px] font-bold">
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((category, index) => {
const colorIndex = colorIndexOffset + index;
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
return (
<TableRow key={category.categoryId}>
<TableCell>
<div className="flex items-center gap-2">
<StatusDot
color={
category.type === "receita"
? "bg-success"
: "bg-destructive"
}
/>
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={colorIndex}
/>
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex items-center gap-1.5 truncate hover:underline underline-offset-2"
>
{category.name}
</Link>
</div>
</TableCell>
{periods.map((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const isFirstMonth = periodIndex === 0;
return (
<TableCell key={period} className="text-right p-0">
<CategoryCell
value={monthData?.amount ?? 0}
previousValue={monthData?.previousAmount ?? 0}
categoryType={category.type}
isFirstMonth={isFirstMonth}
/>
</TableCell>
);
})}
<TableCell className="text-right font-semibold text-info">
{formatCurrency(category.total / Math.max(periods.length, 1))}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(category.total)}
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-bold">Total</TableCell>
{periods.map((period) => {
const periodTotal = sectionTotals.totalsMap.get(period) ?? 0;
return (
<TableCell key={period} className="text-right font-semibold">
{formatCurrency(periodTotal)}
</TableCell>
);
})}
<TableCell className="text-right font-semibold text-info">
{formatCurrency(sectionTotals.averageMonthlyTotal)}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(sectionTotals.grandTotal)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { RiStore2Line } from "@remixicon/react";
import type { TopEstabelecimentosData } from "@/features/reports/establishments/queries";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Progress } from "@/shared/components/ui/progress";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
type EstablishmentsListProps = {
establishments: TopEstabelecimentosData["establishments"];
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "ES";
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "ES";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "ES";
};
export function EstablishmentsList({
establishments,
}: EstablishmentsListProps) {
if (establishments.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiStore2Line className="size-4 text-primary" />
Top Estabelecimentos
</CardTitle>
</CardHeader>
<CardContent>
<WidgetEmptyState
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
title="Nenhum estabelecimento encontrado"
description="Quando houver compras registradas, elas aparecerão aqui."
/>
</CardContent>
</Card>
);
}
const maxCount = Math.max(...establishments.map((e) => e.count));
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiStore2Line className="size-4 text-primary" />
Top Estabelecimentos por Frequência
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col">
{establishments.map((establishment, index) => {
const _initials = buildInitials(establishment.name);
return (
<div
key={establishment.name}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Rank number - same size as icon containers */}
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted">
<span className="text-sm font-semibold text-muted-foreground">
{index + 1}
</span>
</div>
{/* Name and categories */}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">
{establishment.name}
</span>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{establishment.categories
.slice(0, 2)
.map((cat, catIndex) => (
<Badge
key={catIndex}
variant="secondary"
className="text-xs px-1.5 py-0 h-5"
>
{cat.name}
</Badge>
))}
</div>
</div>
</div>
{/* Value and stats */}
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={establishment.totalAmount}
/>
<span className="text-xs text-muted-foreground">
{establishment.count}x Média:{" "}
<MoneyValues
className="text-xs"
amount={establishment.avgAmount}
/>
</span>
</div>
</div>
{/* Progress bar */}
<div className="ml-12 mt-1.5">
<Progress
className="h-1.5"
value={(establishment.count / maxCount) * 100}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { RiFireLine, RiTrophyLine } from "@remixicon/react";
import type { TopEstabelecimentosData } from "@/features/reports/establishments/queries";
import { Card, CardContent } from "@/shared/components/ui/card";
type HighlightsCardsProps = {
summary: TopEstabelecimentosData["summary"];
};
export function HighlightsCards({ summary }: HighlightsCardsProps) {
return (
<div className="grid gap-3 sm:grid-cols-2">
<Card className="bg-linear-to-br from-violet-50 to-violet-50/50 dark:from-violet-950/20 dark:to-violet-950/10">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-violet-100 dark:bg-violet-900/40">
<RiTrophyLine className="size-5 text-violet-600 dark:text-violet-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-violet-700/80 dark:text-violet-400/80 font-medium">
Mais Frequente
</p>
<p className="font-bold text-xl text-violet-900 dark:text-violet-100 truncate">
{summary.mostFrequent || "—"}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-linear-to-br from-red-50 to-rose-50/50 dark:from-red-950/20 dark:to-rose-950/10">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-red-100 dark:bg-red-900/40">
<RiFireLine className="size-5 text-red-600 dark:text-red-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-red-700/80 dark:text-red-400/80 font-medium">
Maior Gasto Total
</p>
<p className="font-bold text-xl text-red-900 dark:text-red-100 truncate">
{summary.highestSpending || "—"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import type { PeriodFilter } from "@/features/reports/establishments/queries";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/utils";
type PeriodFilterProps = {
currentFilter: PeriodFilter;
};
const filterOptions: { value: PeriodFilter; label: string }[] = [
{ value: "3", label: "3 meses" },
{ value: "6", label: "6 meses" },
{ value: "12", label: "12 meses" },
];
export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const handleFilterChange = (filter: PeriodFilter) => {
const params = new URLSearchParams(searchParams.toString());
params.set("meses", filter);
router.push(`/reports/establishments?${params.toString()}`);
};
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{filterOptions.map((option) => (
<Button
key={option.value}
variant={currentFilter === option.value ? "default" : "outline"}
size="sm"
onClick={() => handleFilterChange(option.value)}
className={cn(
"h-8",
currentFilter === option.value && "pointer-events-none",
)}
>
{option.label}
</Button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import {
RiExchangeLine,
RiMoneyDollarCircleLine,
RiRepeatLine,
RiStore2Line,
} from "@remixicon/react";
import type { TopEstabelecimentosData } from "@/features/reports/establishments/queries";
import MoneyValues from "@/shared/components/money-values";
import { Card, CardContent } from "@/shared/components/ui/card";
type SummaryCardsProps = {
summary: TopEstabelecimentosData["summary"];
};
export function SummaryCards({ summary }: SummaryCardsProps) {
const cards = [
{
title: "Estabelecimentos",
value: summary.totalEstablishments,
isMoney: false,
icon: RiStore2Line,
description: "Locais diferentes",
},
{
title: "Transações",
value: summary.totalTransactions,
isMoney: false,
icon: RiExchangeLine,
description: "Compras no período",
},
{
title: "Total Gasto",
value: summary.totalSpent,
isMoney: true,
icon: RiMoneyDollarCircleLine,
description: "Soma de todas as compras",
},
{
title: "Ticket Médio",
value: summary.avgPerTransaction,
isMoney: true,
icon: RiRepeatLine,
description: "Média por transação",
},
];
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardContent className="px-4 py-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{card.title}
</p>
{card.isMoney ? (
<MoneyValues
className="text-2xl font-semibold"
amount={card.value}
/>
) : (
<p className="text-2xl font-semibold">{card.value}</p>
)}
<p className="text-xs text-muted-foreground">
{card.description}
</p>
</div>
<card.icon className="size-5 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { RiPriceTag3Line } from "@remixicon/react";
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
import type { TopEstabelecimentosData } from "@/features/reports/establishments/queries";
import MoneyValues from "@/shared/components/money-values";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Progress } from "@/shared/components/ui/progress";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
type TopCategoriesProps = {
categories: TopEstabelecimentosData["topCategories"];
};
export function TopCategories({ categories }: TopCategoriesProps) {
if (categories.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPriceTag3Line className="size-4 text-primary" />
Principais Categorias
</CardTitle>
</CardHeader>
<CardContent>
<WidgetEmptyState
icon={<RiPriceTag3Line className="size-6 text-muted-foreground" />}
title="Nenhuma categoria encontrada"
description="Quando houver despesas categorizadas, elas aparecerão aqui."
/>
</CardContent>
</Card>
);
}
const totalAmount = categories.reduce((acc, c) => acc + c.totalAmount, 0);
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPriceTag3Line className="size-4 text-primary" />
Principais Categorias
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col">
{categories.map((category, index) => {
const percent =
totalAmount > 0 ? (category.totalAmount / totalAmount) * 100 : 0;
return (
<div
key={category.id}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={index}
/>
{/* Name and percentage */}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">
{category.name}
</span>
<span className="text-xs text-muted-foreground">
{percent.toFixed(0)}% do total {" "}
{category.transactionCount}x
</span>
</div>
</div>
{/* Value */}
<div className="flex shrink-0 flex-col items-end">
<MoneyValues
className="text-foreground"
amount={category.totalAmount}
/>
</div>
</div>
{/* Progress bar */}
<div className="ml-11 mt-1.5">
<Progress className="h-1.5" value={percent} />
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
/**
* UI types for Category Report components
*/
/**
* Category option for report filters
* Includes type field for filtering despesas/receitas
*/
export interface CategoryOption {
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}
/**
* Filter state for category report
* Manages selected categories and date range
*/
export interface FilterState {
selectedCategories: string[]; // Array of category IDs
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
}
/**
* Props for CategoryReportFilters component
*/
export interface CategoryReportFiltersProps {
categories: CategoryOption[];
filters: FilterState;
onFiltersChange: (filters: FilterState) => void;
isLoading?: boolean;
}

View File

@@ -0,0 +1,270 @@
import {
and,
count,
desc,
eq,
gte,
ilike,
isNull,
lte,
ne,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { categorias, contas, lancamentos, pagadores } 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 { safeToNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
const DESPESA = "Despesa";
const TRANSFERENCIA = "Transferência";
export type EstablishmentData = {
name: string;
count: number;
totalAmount: number;
avgAmount: number;
categories: { name: string; count: number }[];
};
export type TopCategoryData = {
id: string;
name: string;
icon: string | null;
totalAmount: number;
transactionCount: number;
};
export type TopEstabelecimentosData = {
establishments: EstablishmentData[];
topCategories: TopCategoryData[];
summary: {
totalEstablishments: number;
totalTransactions: number;
totalSpent: number;
avgPerTransaction: number;
mostFrequent: string | null;
highestSpending: string | null;
};
periodLabel: string;
};
export type PeriodFilter = "3" | "6" | "12";
function buildPeriodRange(currentPeriod: string, months: number): string[] {
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < months; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
return periods;
}
export async function fetchTopEstabelecimentosData(
userId: string,
currentPeriod: string,
periodFilter: PeriodFilter = "6",
): Promise<TopEstabelecimentosData> {
const months = parseInt(periodFilter, 10);
const periods = buildPeriodRange(currentPeriod, months);
const startPeriod = periods[0];
// Fetch establishments with transaction count and total amount
const establishmentsData = await db
.select({
name: lancamentos.name,
count: count().as("count"),
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.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),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.name)
.orderBy(desc(sql`count`))
.limit(50);
// Fetch categories for each establishment
const _establishmentNames = establishmentsData.map(
(e: (typeof establishmentsData)[0]) => e.name,
);
const categoriesByEstablishment = await db
.select({
establishmentName: lancamentos.name,
categoriaId: lancamentos.categoriaId,
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
),
)
.groupBy(lancamentos.name, lancamentos.categoriaId);
// Fetch all category names
const allCategories = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(eq(categorias.userId, userId));
type CategoryInfo = { id: string; name: string; icon: string | null };
const categoryMap = new Map<string, CategoryInfo>(
allCategories.map((c): [string, CategoryInfo] => [c.id, c as CategoryInfo]),
);
// Build establishment data with categories
type EstablishmentRow = (typeof establishmentsData)[0];
type CategoryByEstRow = (typeof categoriesByEstablishment)[0];
const establishments: EstablishmentData[] = establishmentsData.map(
(est: EstablishmentRow) => {
const cnt = Number(est.count) || 0;
const total = Math.abs(safeToNumber(est.totalAmount));
const estCategories = categoriesByEstablishment
.filter(
(c: CategoryByEstRow) =>
c.establishmentName === est.name && c.categoriaId,
)
.map((c: CategoryByEstRow) => ({
name:
categoryMap.get(c.categoriaId as string)?.name || "Sem categoria",
count: Number(c.count) || 0,
}))
.sort(
(
a: { name: string; count: number },
b: { name: string; count: number },
) => b.count - a.count,
)
.slice(0, 3);
return {
name: est.name,
count: cnt,
totalAmount: total,
avgAmount: cnt > 0 ? total / cnt : 0,
categories: estCategories,
};
},
);
// Fetch top categories by spending
const topCategoriesData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.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),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.categoriaId)
.orderBy(sql`total ASC`)
.limit(10);
type TopCategoryRow = (typeof topCategoriesData)[0];
const topCategories: TopCategoryData[] = topCategoriesData
.filter((c: TopCategoryRow) => c.categoriaId)
.map((cat: TopCategoryRow) => {
const catInfo = categoryMap.get(cat.categoriaId as string);
return {
id: cat.categoriaId as string,
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
totalAmount: Math.abs(safeToNumber(cat.totalAmount)),
transactionCount: Number(cat.count) || 0,
};
});
// Calculate summary
const totalTransactions = establishments.reduce((acc, e) => acc + e.count, 0);
const totalSpent = establishments.reduce((acc, e) => acc + e.totalAmount, 0);
const mostFrequent =
establishments.length > 0 ? establishments[0].name : null;
const sortedBySpending = [...establishments].sort(
(a, b) => b.totalAmount - a.totalAmount,
);
const highestSpending =
sortedBySpending.length > 0 ? sortedBySpending[0].name : null;
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
return {
establishments,
topCategories,
summary: {
totalEstablishments: establishments.length,
totalTransactions,
totalSpent,
avgPerTransaction:
totalTransactions > 0 ? totalSpent / totalTransactions : 0,
mostFrequent,
highestSpending,
},
periodLabel,
};
}

View File

@@ -0,0 +1,7 @@
export type {
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
DateRangeValidation,
MonthlyData,
} from "@/shared/lib/types/reports";

View File

@@ -0,0 +1,105 @@
import type { DateRangeValidation } from "@/shared/lib/types/reports";
import { calculatePercentageChange } from "@/shared/utils/math";
import { formatPercentageChange as formatPercentageChangeValue } from "@/shared/utils/percentage";
import {
buildPeriodRange,
formatShortPeriodLabel,
parsePeriod,
} from "@/shared/utils/period";
// Re-export for convenience
export { calculatePercentageChange };
/**
* Formats period string from "YYYY-MM" to "MMM/YYYY" format
* Example: "2025-01" -> "Jan/2025"
*
* @param period - Period in YYYY-MM format
* @returns Formatted period string
*/
export function formatPeriodLabel(period: string): string {
try {
parsePeriod(period);
return formatShortPeriodLabel(period);
} catch {
return period; // Return original if parsing fails
}
}
/**
* Generates an array of periods between start and end (inclusive)
* Alias for buildPeriodRange from period utils
*
* @param startPeriod - Start period in YYYY-MM format
* @param endPeriod - End period in YYYY-MM format
* @returns Array of period strings in chronological order
*/
export function generatePeriodRange(
startPeriod: string,
endPeriod: string,
): string[] {
return buildPeriodRange(startPeriod, endPeriod);
}
/**
* Validates that end date is >= start date and period is within limits
* Maximum allowed period: 24 months
*
* @param startPeriod - Start period in YYYY-MM format
* @param endPeriod - End period in YYYY-MM format
* @returns Validation result with error message if invalid
*/
export function validateDateRange(
startPeriod: string,
endPeriod: string,
): DateRangeValidation {
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",
};
}
// 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",
};
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Formato de período inválido. Use YYYY-MM",
};
}
}
/**
* Formats percentage change for display
* Format: "±X%" or "±X.X%" (one decimal if < 10%)
*
* @param change - Percentage change value
* @returns Formatted percentage string
*/
export function formatPercentageChange(change: number | null): string {
return formatPercentageChangeValue(change);
}