diff --git a/app/(dashboard)/relatorios/cartoes/loading.tsx b/app/(dashboard)/relatorios/cartoes/loading.tsx new file mode 100644 index 0000000..557d6e0 --- /dev/null +++ b/app/(dashboard)/relatorios/cartoes/loading.tsx @@ -0,0 +1,85 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ + + +
+
+ + + + + +
+ + + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+
+ +
+ + + + + + + + + + + +
+ + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + + + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+ + + + + + + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + +
+
+
+ ); +} diff --git a/app/(dashboard)/relatorios/cartoes/page.tsx b/app/(dashboard)/relatorios/cartoes/page.tsx new file mode 100644 index 0000000..61486e2 --- /dev/null +++ b/app/(dashboard)/relatorios/cartoes/page.tsx @@ -0,0 +1,96 @@ +import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown"; +import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status"; +import { CardTopExpenses } from "@/components/relatorios/cartoes/card-top-expenses"; +import { CardUsageChart } from "@/components/relatorios/cartoes/card-usage-chart"; +import { CardsOverview } from "@/components/relatorios/cartoes/cards-overview"; +import MonthNavigation from "@/components/month-picker/month-navigation"; +import { getUser } from "@/lib/auth/server"; +import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report"; +import { parsePeriodParam } from "@/lib/utils/period"; +import { RiBankCard2Line } from "@remixicon/react"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string, +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; +}; + +export default async function RelatorioCartoesPage({ + searchParams, +}: PageProps) { + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const cartaoParam = getSingleParam(resolvedSearchParams, "cartao"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); + + const data = await fetchCartoesReportData( + user.id, + selectedPeriod, + cartaoParam, + ); + + return ( +
+
+

+ Relatório de Cartões +

+

+ Análise detalhada do uso dos seus cartões de crédito. +

+
+ + + +
+
+ +
+ +
+ {data.selectedCard ? ( + <> +
+

+ {data.selectedCard.card.name} +

+
+ + + +
+ + +
+ + + + ) : ( +
+ +

Nenhum cartão selecionado

+

+ Selecione um cartão na lista ao lado para ver detalhes. +

+
+ )} +
+
+
+ ); +} diff --git a/components/relatorios/cartoes/card-category-breakdown.tsx b/components/relatorios/cartoes/card-category-breakdown.tsx new file mode 100644 index 0000000..21262c0 --- /dev/null +++ b/components/relatorios/cartoes/card-category-breakdown.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; +import { getIconComponent } from "@/lib/utils/icons"; +import { RiPieChartLine } from "@remixicon/react"; + +type CardCategoryBreakdownProps = { + data: CardDetailData["categoryBreakdown"]; +}; + +const COLORS = [ + "#ef4444", + "#3b82f6", + "#10b981", + "#f59e0b", + "#8b5cf6", + "#ec4899", + "#14b8a6", + "#f97316", + "#6366f1", + "#84cc16", +]; + +export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + }; + + if (data.length === 0) { + return ( + + + + Gastos por Categoria + + + +
+ +

Nenhum gasto neste período

+
+
+
+ ); + } + + return ( + + + + Gastos por Categoria + + + + {data.map((category, index) => { + const IconComponent = category.icon + ? getIconComponent(category.icon) + : null; + const color = COLORS[index % COLORS.length]; + + return ( +
+
+
+ {IconComponent ? ( + + ) : ( +
+ )} + + {category.name} + +
+ + {formatCurrency(category.amount)} + +
+
+
+
+
+ + {category.percent.toFixed(0)}% + +
+
+ ); + })} + + + ); +} diff --git a/components/relatorios/cartoes/card-invoice-status.tsx b/components/relatorios/cartoes/card-invoice-status.tsx new file mode 100644 index 0000000..e0c853e --- /dev/null +++ b/components/relatorios/cartoes/card-invoice-status.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; + +type CardInvoiceStatusProps = { + data: CardDetailData["invoiceStatus"]; +}; + +const monthLabels = [ + "Jan", + "Fev", + "Mar", + "Abr", + "Mai", + "Jun", + "Jul", + "Ago", + "Set", + "Out", + "Nov", + "Dez", +]; + +export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + const getStatusBadge = (status: string | null) => { + switch (status) { + case "pago": + return ( + + Pago + + ); + case "pendente": + return ( + + Pendente + + ); + case "atrasado": + return ( + + Atrasado + + ); + default: + return ( + + — + + ); + } + }; + + const formatPeriod = (period: string) => { + const [year, month] = period.split("-"); + return `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`; + }; + + return ( + + + + Status das Faturas + + + +
+ {data.map((invoice) => ( +
+
+ + {formatPeriod(invoice.period)} + + {getStatusBadge(invoice.status)} +
+ + {formatCurrency(invoice.amount)} + +
+ ))} +
+
+
+ ); +} diff --git a/components/relatorios/cartoes/card-top-expenses.tsx b/components/relatorios/cartoes/card-top-expenses.tsx new file mode 100644 index 0000000..c187964 --- /dev/null +++ b/components/relatorios/cartoes/card-top-expenses.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; +import { RiShoppingBag3Line } from "@remixicon/react"; + +type CardTopExpensesProps = { + data: CardDetailData["topExpenses"]; +}; + +export function CardTopExpenses({ data }: CardTopExpensesProps) { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + }; + + if (data.length === 0) { + return ( + + + + Maiores Gastos + + + +
+ +

Nenhum gasto neste período

+
+
+
+ ); + } + + return ( + + + + Top 10 Gastos do Mês + + + +
+ {data.map((expense, index) => ( +
+
+ + {index + 1}. + +
+

+ {expense.name} +

+
+ {expense.date} + {expense.category && ( + <> + + + {expense.category} + + + )} +
+
+
+ + {formatCurrency(expense.amount)} + +
+ ))} +
+
+
+ ); +} diff --git a/components/relatorios/cartoes/card-usage-chart.tsx b/components/relatorios/cartoes/card-usage-chart.tsx new file mode 100644 index 0000000..dad2442 --- /dev/null +++ b/components/relatorios/cartoes/card-usage-chart.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + type ChartConfig, +} from "@/components/ui/chart"; +import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis, ReferenceLine } from "recharts"; + +type CardUsageChartProps = { + data: CardDetailData["monthlyUsage"]; + limit: number; +}; + +const chartConfig = { + amount: { + label: "Uso", + color: "#3b82f6", + }, +} satisfies ChartConfig; + +export function CardUsageChart({ data, limit }: CardUsageChartProps) { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + const formatCurrencyCompact = (value: number) => { + if (Math.abs(value) >= 1000) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + notation: "compact", + }).format(value); + } + return formatCurrency(value); + }; + + const chartData = data.map((item) => ({ + month: item.periodLabel, + amount: item.amount, + })); + + return ( + + + + Uso Mensal (6 meses) + + + + + + + + + {limit > 0 && ( + + )} + { + 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 ( +
+
+ {data.month} +
+
+
+ + Uso + + + {formatCurrency(value)} + +
+ {limit > 0 && ( +
+ + % do Limite + + + {usagePercent.toFixed(0)}% + +
+ )} +
+
+ ); + }} + cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }} + /> + +
+
+
+
+ ); +} diff --git a/components/relatorios/cartoes/cards-overview.tsx b/components/relatorios/cartoes/cards-overview.tsx new file mode 100644 index 0000000..e78d77a --- /dev/null +++ b/components/relatorios/cartoes/cards-overview.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import type { CartoesReportData } from "@/lib/relatorios/cartoes-report"; +import { + RiBankCard2Line, + RiArrowUpLine, + RiArrowDownLine, +} from "@remixicon/react"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import Image from "next/image"; +import { useSearchParams } from "next/navigation"; + +type CardsOverviewProps = { + data: CartoesReportData; +}; + +const BRAND_ASSETS: Record = { + visa: "/bandeiras/visa.svg", + mastercard: "/bandeiras/mastercard.svg", + amex: "/bandeiras/amex.svg", + american: "/bandeiras/amex.svg", + elo: "/bandeiras/elo.svg", + hipercard: "/bandeiras/hipercard.svg", + hiper: "/bandeiras/hipercard.svg", +}; + +const resolveBrandAsset = (brand: string | null) => { + if (!brand) return null; + const normalized = brand.trim().toLowerCase(); + const match = ( + Object.keys(BRAND_ASSETS) as Array + ).find((entry) => normalized.includes(entry)); + return match ? BRAND_ASSETS[match] : null; +}; + +const resolveLogoPath = (logo: string | null) => { + if (!logo) return null; + if ( + logo.startsWith("http://") || + logo.startsWith("https://") || + logo.startsWith("data:") + ) { + return logo; + } + return logo.startsWith("/") ? logo : `/logos/${logo}`; +}; + +export function CardsOverview({ data }: CardsOverviewProps) { + const searchParams = useSearchParams(); + const periodoParam = searchParams.get("periodo"); + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + const getUsageColor = (percent: number) => { + if (percent < 50) return "bg-green-500"; + if (percent < 80) return "bg-yellow-500"; + return "bg-red-500"; + }; + + const buildUrl = (cardId: string) => { + const params = new URLSearchParams(); + if (periodoParam) params.set("periodo", periodoParam); + params.set("cartao", cardId); + return `/relatorios/cartoes?${params.toString()}`; + }; + + if (data.cards.length === 0) { + return ( + + + + Resumo dos Cartões + + + +
+ +

Nenhum cartão ativo encontrado

+
+
+
+ ); + } + + return ( + + + + Resumo dos Cartões + + + +
+
+

Limite Total

+

+ {formatCurrency(data.totalLimit)} +

+
+
+

Uso Total

+

+ {formatCurrency(data.totalUsage)} +

+
+
+

Utilização

+

+ {data.totalUsagePercent.toFixed(0)}% +

+
+
+ +
+ {data.cards.map((card) => { + const logoPath = resolveLogoPath(card.logo); + const brandAsset = resolveBrandAsset(card.brand); + + return ( + +
+
+ {/* Logo container - size-10 like expenses-by-category */} +
+ {logoPath ? ( + {`Logo + ) : ( + + )} +
+ + {/* Name and brand */} +
+
+ + {card.name} + + {brandAsset && ( + {`Bandeira + )} +
+
+ + {formatCurrency(card.currentUsage)} /{" "} + {formatCurrency(card.limit)} + +
+
+
+ + {/* Trend and percentage */} +
+ + {card.usagePercent.toFixed(0)}% + +
+ {card.trend === "up" && ( + + )} + {card.trend === "down" && ( + + )} + + {card.changePercent > 0 ? "+" : ""} + {card.changePercent.toFixed(0)}% + +
+
+
+ + {/* Progress bar - aligned with content */} +
+ div]:${getUsageColor(card.usagePercent)}`, + )} + /> +
+ + ); + })} +
+
+
+ ); +} diff --git a/lib/relatorios/cartoes-report.ts b/lib/relatorios/cartoes-report.ts new file mode 100644 index 0000000..b2f9b1c --- /dev/null +++ b/lib/relatorios/cartoes-report.ts @@ -0,0 +1,407 @@ +import { lancamentos, pagadores, cartoes, categorias, faturas } from "@/db/schema"; +import { db } from "@/lib/db"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { getPreviousPeriod } from "@/lib/utils/period"; +import { safeToNumber } from "@/lib/utils/number"; +import { and, eq, sum, gte, lte, inArray, not, ilike } from "drizzle-orm"; + +const DESPESA = "Despesa"; + +export type CardSummary = { + id: string; + name: string; + brand: string | null; + logo: string | null; + limit: number; + currentUsage: number; + usagePercent: number; + previousUsage: number; + changePercent: number; + trend: "up" | "down" | "stable"; + status: string; +}; + +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; +}; + +export async function fetchCartoesReportData( + userId: string, + currentPeriod: string, + selectedCartaoId?: string | null +): Promise { + 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")))); + + 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 + const currentUsageData = await db + .select({ + cartaoId: lancamentos.cartaoId, + totalAmount: sum(lancamentos.amount).as("total"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, currentPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA), + inArray(lancamentos.cartaoId, cardIds) + ) + ) + .groupBy(lancamentos.cartaoId); + + // Fetch previous period usage by card + const previousUsageData = await db + .select({ + cartaoId: lancamentos.cartaoId, + totalAmount: sum(lancamentos.amount).as("total"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, previousPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA), + inArray(lancamentos.cartaoId, cardIds) + ) + ) + .groupBy(lancamentos.cartaoId); + + const currentUsageMap = new Map(); + for (const row of currentUsageData) { + if (row.cartaoId) { + currentUsageMap.set(row.cartaoId, Math.abs(safeToNumber(row.totalAmount))); + } + } + + const previousUsageMap = new Map(); + 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 { + // Build period range for last 6 months + const periods: string[] = []; + let p = currentPeriod; + for (let i = 0; i < 6; i++) { + periods.unshift(p); + p = getPreviousPeriod(p); + } + + const startPeriod = periods[0]; + + const monthLabels = [ + "Jan", + "Fev", + "Mar", + "Abr", + "Mai", + "Jun", + "Jul", + "Ago", + "Set", + "Out", + "Nov", + "Dez", + ]; + + // Fetch monthly usage + const monthlyData = await db + .select({ + period: lancamentos.period, + totalAmount: sum(lancamentos.amount).as("total"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.cartaoId, cardId), + gte(lancamentos.period, startPeriod), + lte(lancamentos.period, currentPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA) + ) + ) + .groupBy(lancamentos.period) + .orderBy(lancamentos.period); + + const monthlyUsage = periods.map((period) => { + const data = monthlyData.find((d) => d.period === period); + const [year, month] = period.split("-"); + return { + period, + periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`, + amount: Math.abs(safeToNumber(data?.totalAmount)), + }; + }); + + // Fetch category breakdown for current period + const categoryData = await db + .select({ + categoriaId: lancamentos.categoriaId, + totalAmount: sum(lancamentos.amount).as("total"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.cartaoId, cardId), + eq(lancamentos.period, currentPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA) + ) + ) + .groupBy(lancamentos.categoriaId); + + // Fetch category names + const categoryIds = categoryData + .map((c) => c.categoriaId) + .filter((id): id is string => id !== null); + + const categoryNames = + categoryIds.length > 0 + ? await db + .select({ + id: categorias.id, + name: categorias.name, + icon: categorias.icon, + }) + .from(categorias) + .where(inArray(categorias.id, categoryIds)) + : []; + + const 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); + + const topExpenses = topExpensesData.map((expense) => { + const catInfo = expense.categoriaId + ? categoryNameMap.get(expense.categoriaId) + : null; + return { + id: expense.id, + name: expense.name, + amount: Math.abs(safeToNumber(expense.amount)), + date: expense.purchaseDate + ? new Date(expense.purchaseDate).toLocaleDateString("pt-BR") + : "", + category: catInfo?.name || null, + }; + }); + + // Fetch invoice status for last 6 months + const invoiceData = await db + .select({ + period: faturas.period, + status: faturas.paymentStatus, + }) + .from(faturas) + .where( + and( + eq(faturas.userId, userId), + eq(faturas.cartaoId, cardId), + gte(faturas.period, startPeriod), + lte(faturas.period, currentPeriod) + ) + ) + .orderBy(faturas.period); + + 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, + }; +}