forked from git.gladyson/openmonetis
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:
85
app/(dashboard)/relatorios/cartoes/loading.tsx
Normal file
85
app/(dashboard)/relatorios/cartoes/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
app/(dashboard)/relatorios/cartoes/page.tsx
Normal file
96
app/(dashboard)/relatorios/cartoes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
components/relatorios/cartoes/card-category-breakdown.tsx
Normal file
110
components/relatorios/cartoes/card-category-breakdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
components/relatorios/cartoes/card-invoice-status.tsx
Normal file
99
components/relatorios/cartoes/card-invoice-status.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
components/relatorios/cartoes/card-top-expenses.tsx
Normal file
83
components/relatorios/cartoes/card-top-expenses.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
components/relatorios/cartoes/card-usage-chart.tsx
Normal file
143
components/relatorios/cartoes/card-usage-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
components/relatorios/cartoes/cards-overview.tsx
Normal file
224
components/relatorios/cartoes/cards-overview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
407
lib/relatorios/cartoes-report.ts
Normal file
407
lib/relatorios/cartoes-report.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user