feat(cartoes): adiciona filtro de período e logo no gráfico de uso

- Adiciona filtros de 3, 6 e 12 meses no CardUsageChart
- Exibe logo e nome do cartão no header do gráfico
- Atualiza fetchCardDetail para buscar 12 meses de dados
This commit is contained in:
Felipe Coutinho
2026-01-20 15:21:02 +00:00
parent 9f0585e3bb
commit 2caf86871a
2 changed files with 128 additions and 35 deletions

View File

@@ -1,17 +1,33 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } 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";
import { cn } from "@/lib/utils";
import { RiBankCard2Line } from "@remixicon/react";
import Image from "next/image";
import { useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ReferenceLine,
XAxis,
YAxis,
} from "recharts";
type CardUsageChartProps = {
data: CardDetailData["monthlyUsage"];
limit: number;
card: {
name: string;
logo: string | null;
};
};
const chartConfig = {
@@ -21,7 +37,29 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function CardUsageChart({ data, limit }: CardUsageChartProps) {
type PeriodFilter = "3" | "6" | "12";
const filterOptions: { value: PeriodFilter; label: string }[] = [
{ value: "3", label: "3 meses" },
{ value: "6", label: "6 meses" },
{ value: "12", label: "12 meses" },
];
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 CardUsageChart({ data, limit, card }: CardUsageChartProps) {
const [period, setPeriod] = useState<PeriodFilter>("6");
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
@@ -44,17 +82,58 @@ export function CardUsageChart({ data, limit }: CardUsageChartProps) {
return formatCurrency(value);
};
const chartData = data.map((item) => ({
// Filter data based on selected period
const filteredData = data.slice(-Number(period));
const chartData = filteredData.map((item) => ({
month: item.periodLabel,
amount: item.amount,
}));
const logoPath = resolveLogoPath(card.logo);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Uso Mensal (6 meses)
</CardTitle>
<div className="flex items-center justify-between">
{/* Card logo and name on the left */}
<div className="flex items-center gap-2">
{logoPath ? (
<div className="flex size-10 shrink-0 items-center justify-center">
<Image
src={logoPath}
alt={`Logo ${card.name}`}
width={32}
height={32}
className="rounded object-contain"
/>
</div>
) : (
<div className="flex size-10 shrink-0 items-center justify-center">
<RiBankCard2Line className="size-5 text-muted-foreground" />
</div>
)}
<span className="text-base font-semibold">{card.name}</span>
</div>
{/* Filters on the right */}
<div className="flex items-center gap-1">
{filterOptions.map((option) => (
<Button
key={option.value}
variant={period === option.value ? "default" : "outline"}
size="sm"
onClick={() => setPeriod(option.value)}
className={cn(
"h-7 text-xs",
period === option.value && "pointer-events-none",
)}
>
{option.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
@@ -131,7 +210,7 @@ export function CardUsageChart({ data, limit }: CardUsageChartProps) {
/>
<Bar
dataKey="amount"
fill="#3b82f6"
fill="var(--primary)"
radius={[4, 4, 0, 0]}
maxBarSize={50}
/>

View File

@@ -1,9 +1,15 @@
import { lancamentos, pagadores, cartoes, categorias, faturas } from "@/db/schema";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} 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";
import { getPreviousPeriod } from "@/lib/utils/period";
import { and, eq, gte, ilike, inArray, lte, not, sum } from "drizzle-orm";
const DESPESA = "Despesa";
@@ -60,7 +66,7 @@ export type CartoesReportData = {
export async function fetchCartoesReportData(
userId: string,
currentPeriod: string,
selectedCartaoId?: string | null
selectedCartaoId?: string | null,
): Promise<CartoesReportData> {
const previousPeriod = getPreviousPeriod(currentPeriod);
@@ -75,7 +81,9 @@ export async function fetchCartoesReportData(
status: cartoes.status,
})
.from(cartoes)
.where(and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))));
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
);
if (allCards.length === 0) {
return {
@@ -103,8 +111,8 @@ export async function fetchCartoesReportData(
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds)
)
inArray(lancamentos.cartaoId, cardIds),
),
)
.groupBy(lancamentos.cartaoId);
@@ -122,15 +130,18 @@ export async function fetchCartoesReportData(
eq(lancamentos.period, previousPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds)
)
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)));
currentUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
@@ -139,7 +150,7 @@ export async function fetchCartoesReportData(
if (row.cartaoId) {
previousUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount))
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
@@ -183,11 +194,13 @@ export async function fetchCartoesReportData(
// 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;
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);
const targetCardId =
selectedCartaoId || (cards.length > 0 ? cards[0].id : null);
if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId);
@@ -196,7 +209,7 @@ export async function fetchCartoesReportData(
userId,
targetCardId,
cardSummary,
currentPeriod
currentPeriod,
);
}
}
@@ -214,12 +227,12 @@ async function fetchCardDetail(
userId: string,
cardId: string,
cardSummary: CardSummary,
currentPeriod: string
currentPeriod: string,
): Promise<CardDetailData> {
// Build period range for last 6 months
// Build period range for last 12 months
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < 6; i++) {
for (let i = 0; i < 12; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
@@ -256,8 +269,8 @@ async function fetchCardDetail(
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA)
)
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.period)
.orderBy(lancamentos.period);
@@ -286,8 +299,8 @@ async function fetchCardDetail(
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA)
)
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.categoriaId);
@@ -312,7 +325,7 @@ async function fetchCardDetail(
const totalCategoryAmount = categoryData.reduce(
(acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)),
0
0,
);
const categoryBreakdown = categoryData
@@ -326,7 +339,8 @@ async function fetchCardDetail(
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
amount,
percent: totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
percent:
totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
};
})
.sort((a, b) => b.amount - a.amount)
@@ -349,8 +363,8 @@ async function fetchCardDetail(
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA)
)
eq(lancamentos.transactionType, DESPESA),
),
)
.orderBy(lancamentos.amount)
.limit(10);
@@ -382,8 +396,8 @@ async function fetchCardDetail(
eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod)
)
lte(faturas.period, currentPeriod),
),
)
.orderBy(faturas.period);