feat(relatorios): adicionar página de relatório de cartões

- Criar página /relatorios/cartoes com visão geral dos cartões
- Adicionar componentes: cards-overview, card-usage-chart, card-top-expenses
- Adicionar componentes: card-category-breakdown, card-invoice-status
- Criar função de busca de dados para relatório de cartões

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-20 13:43:26 +00:00
parent 7670b26a8c
commit c55b808bb6
8 changed files with 1247 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-10 w-full max-w-md" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="lg:col-span-2 space-y-4">
<Skeleton className="h-8 w-48" />
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | 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 (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">
Relatório de Cartões
</h1>
<p className="text-sm text-muted-foreground">
Análise detalhada do uso dos seus cartões de crédito.
</p>
</div>
<MonthNavigation />
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<CardsOverview data={data} />
</div>
<div className="lg:col-span-2 space-y-4">
{data.selectedCard ? (
<>
<div className="flex items-center gap-2 pb-2 border-b">
<h2 className="text-lg font-semibold">
{data.selectedCard.card.name}
</h2>
</div>
<CardUsageChart
data={data.selectedCard.monthlyUsage}
limit={data.selectedCard.card.limit}
/>
<div className="grid gap-4 md:grid-cols-2">
<CardCategoryBreakdown
data={data.selectedCard.categoryBreakdown}
/>
<CardTopExpenses data={data.selectedCard.topExpenses} />
</div>
<CardInvoiceStatus data={data.selectedCard.invoiceStatus} />
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<RiBankCard2Line className="size-12 mb-4" />
<p className="text-lg font-medium">Nenhum cartão selecionado</p>
<p className="text-sm">
Selecione um cartão na lista ao lado para ver detalhes.
</p>
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Gastos por Categoria
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<RiPieChartLine className="size-8 mb-2" />
<p className="text-sm">Nenhum gasto neste período</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Gastos por Categoria
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.map((category, index) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const color = COLORS[index % COLORS.length];
return (
<div key={category.id} className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{IconComponent ? (
<IconComponent
className="size-4"
style={{ color }}
/>
) : (
<div
className="size-4 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-sm font-medium truncate max-w-[150px]">
{category.name}
</span>
</div>
<span className="text-sm text-muted-foreground">
{formatCurrency(category.amount)}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${category.percent}%`,
backgroundColor: color,
}}
/>
</div>
<span className="text-xs text-muted-foreground w-10 text-right">
{category.percent.toFixed(0)}%
</span>
</div>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-400 dark:border-green-900">
Pago
</Badge>
);
case "pendente":
return (
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-950 dark:text-yellow-400 dark:border-yellow-900">
Pendente
</Badge>
);
case "atrasado":
return (
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-400 dark:border-red-900">
Atrasado
</Badge>
);
default:
return (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
);
}
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
return `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`;
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Status das Faturas
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{data.map((invoice) => (
<div
key={invoice.period}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium w-16">
{formatPeriod(invoice.period)}
</span>
{getStatusBadge(invoice.status)}
</div>
<span className="text-sm text-muted-foreground">
{formatCurrency(invoice.amount)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Maiores Gastos
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<RiShoppingBag3Line className="size-8 mb-2" />
<p className="text-sm">Nenhum gasto neste período</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Top 10 Gastos do Mês
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{data.map((expense, index) => (
<div
key={expense.id}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-xs text-muted-foreground w-5">
{index + 1}.
</span>
<div className="min-w-0">
<p className="text-sm font-medium truncate max-w-[200px]">
{expense.name}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{expense.date}</span>
{expense.category && (
<>
<span></span>
<span className="truncate max-w-[100px]">
{expense.category}
</span>
</>
)}
</div>
</div>
</div>
<span className="text-sm font-medium text-red-500 shrink-0">
{formatCurrency(expense.amount)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Uso Mensal (6 meses)
</CardTitle>
</CardHeader>
<CardContent>
<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={formatCurrencyCompact}
/>
{limit > 0 && (
<ReferenceLine
y={limit}
stroke="#ef4444"
strokeDasharray="3 3"
label={{
value: "Limite",
position: "right",
className: "text-xs fill-red-500",
}}
/>
)}
<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)}
</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">
{usagePercent.toFixed(0)}%
</span>
</div>
)}
</div>
</div>
);
}}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
/>
<Bar
dataKey="amount"
fill="#3b82f6"
radius={[4, 4, 0, 0]}
maxBarSize={50}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -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<string, string> = {
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<keyof typeof BRAND_ASSETS>
).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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Resumo dos Cartões
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<RiBankCard2Line className="size-8 mb-2" />
<p className="text-sm">Nenhum cartão ativo encontrado</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Resumo dos Cartões
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="p-3 rounded-lg border bg-muted/30">
<p className="text-xs text-muted-foreground">Limite Total</p>
<p className="text-lg font-semibold">
{formatCurrency(data.totalLimit)}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/30">
<p className="text-xs text-muted-foreground">Uso Total</p>
<p className="text-lg font-semibold">
{formatCurrency(data.totalUsage)}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/30">
<p className="text-xs text-muted-foreground">Utilização</p>
<p className="text-lg font-semibold">
{data.totalUsagePercent.toFixed(0)}%
</p>
</div>
</div>
<div className="flex flex-col">
{data.cards.map((card) => {
const logoPath = resolveLogoPath(card.logo);
const brandAsset = resolveBrandAsset(card.brand);
return (
<Link
key={card.id}
href={buildUrl(card.id)}
className={cn(
"flex flex-col py-2 border-b border-dashed last:border-0 transition-colors hover:bg-muted/50",
data.selectedCard?.card.id === card.id && "bg-muted/30",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Logo container - size-10 like expenses-by-category */}
<div className="flex size-10 shrink-0 items-center justify-center">
{logoPath ? (
<Image
src={logoPath}
alt={`Logo ${card.name}`}
width={28}
height={28}
className="rounded object-contain"
/>
) : (
<RiBankCard2Line className="size-4 text-muted-foreground" />
)}
</div>
{/* Name and brand */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{card.name}
</span>
{brandAsset && (
<Image
src={brandAsset}
alt={`Bandeira ${card.brand}`}
width={24}
height={16}
className="h-2.5 w-auto shrink-0"
/>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatCurrency(card.currentUsage)} /{" "}
{formatCurrency(card.limit)}
</span>
</div>
</div>
</div>
{/* Trend and percentage */}
<div className="flex shrink-0 flex-col items-end gap-0.5">
<span className="text-sm font-medium">
{card.usagePercent.toFixed(0)}%
</span>
<div className="flex items-center gap-1">
{card.trend === "up" && (
<RiArrowUpLine className="size-3 text-red-500" />
)}
{card.trend === "down" && (
<RiArrowDownLine className="size-3 text-green-500" />
)}
<span
className={cn(
"text-xs",
card.trend === "up" && "text-red-500",
card.trend === "down" && "text-green-500",
card.trend === "stable" && "text-muted-foreground",
)}
>
{card.changePercent > 0 ? "+" : ""}
{card.changePercent.toFixed(0)}%
</span>
</div>
</div>
</div>
{/* Progress bar - aligned with content */}
<div className="ml-12 mt-1.5">
<Progress
value={Math.min(card.usagePercent, 100)}
className={cn(
"h-1.5",
`[&>div]:${getUsageColor(card.usagePercent)}`,
)}
/>
</div>
</Link>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<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"))));
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<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 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,
};
}