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"; "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 { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
type ChartConfig, type ChartConfig,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; 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 = { type CardUsageChartProps = {
data: CardDetailData["monthlyUsage"]; data: CardDetailData["monthlyUsage"];
limit: number; limit: number;
card: {
name: string;
logo: string | null;
};
}; };
const chartConfig = { const chartConfig = {
@@ -21,7 +37,29 @@ const chartConfig = {
}, },
} satisfies 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) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", { return new Intl.NumberFormat("pt-BR", {
style: "currency", style: "currency",
@@ -44,17 +82,58 @@ export function CardUsageChart({ data, limit }: CardUsageChartProps) {
return formatCurrency(value); 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, month: item.periodLabel,
amount: item.amount, amount: item.amount,
})); }));
const logoPath = resolveLogoPath(card.logo);
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base font-medium"> <div className="flex items-center justify-between">
Uso Mensal (6 meses) {/* Card logo and name on the left */}
</CardTitle> <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> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full"> <ChartContainer config={chartConfig} className="h-[280px] w-full">
@@ -131,7 +210,7 @@ export function CardUsageChart({ data, limit }: CardUsageChartProps) {
/> />
<Bar <Bar
dataKey="amount" dataKey="amount"
fill="#3b82f6" fill="var(--primary)"
radius={[4, 4, 0, 0]} radius={[4, 4, 0, 0]}
maxBarSize={50} 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 { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getPreviousPeriod } from "@/lib/utils/period";
import { safeToNumber } from "@/lib/utils/number"; 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"; const DESPESA = "Despesa";
@@ -60,7 +66,7 @@ export type CartoesReportData = {
export async function fetchCartoesReportData( export async function fetchCartoesReportData(
userId: string, userId: string,
currentPeriod: string, currentPeriod: string,
selectedCartaoId?: string | null selectedCartaoId?: string | null,
): Promise<CartoesReportData> { ): Promise<CartoesReportData> {
const previousPeriod = getPreviousPeriod(currentPeriod); const previousPeriod = getPreviousPeriod(currentPeriod);
@@ -75,7 +81,9 @@ export async function fetchCartoesReportData(
status: cartoes.status, status: cartoes.status,
}) })
.from(cartoes) .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) { if (allCards.length === 0) {
return { return {
@@ -103,8 +111,8 @@ export async function fetchCartoesReportData(
eq(lancamentos.period, currentPeriod), eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA), eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds) inArray(lancamentos.cartaoId, cardIds),
) ),
) )
.groupBy(lancamentos.cartaoId); .groupBy(lancamentos.cartaoId);
@@ -122,15 +130,18 @@ export async function fetchCartoesReportData(
eq(lancamentos.period, previousPeriod), eq(lancamentos.period, previousPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA), eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds) inArray(lancamentos.cartaoId, cardIds),
) ),
) )
.groupBy(lancamentos.cartaoId); .groupBy(lancamentos.cartaoId);
const currentUsageMap = new Map<string, number>(); const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) { for (const row of currentUsageData) {
if (row.cartaoId) { 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) { if (row.cartaoId) {
previousUsageMap.set( previousUsageMap.set(
row.cartaoId, row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)) Math.abs(safeToNumber(row.totalAmount)),
); );
} }
} }
@@ -183,11 +194,13 @@ export async function fetchCartoesReportData(
// Calculate totals // Calculate totals
const totalLimit = cards.reduce((acc, c) => acc + c.limit, 0); const totalLimit = cards.reduce((acc, c) => acc + c.limit, 0);
const totalUsage = cards.reduce((acc, c) => acc + c.currentUsage, 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 // Fetch selected card details if provided
let selectedCard: CardDetailData | null = null; 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) { if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId); const cardSummary = cards.find((c) => c.id === targetCardId);
@@ -196,7 +209,7 @@ export async function fetchCartoesReportData(
userId, userId,
targetCardId, targetCardId,
cardSummary, cardSummary,
currentPeriod currentPeriod,
); );
} }
} }
@@ -214,12 +227,12 @@ async function fetchCardDetail(
userId: string, userId: string,
cardId: string, cardId: string,
cardSummary: CardSummary, cardSummary: CardSummary,
currentPeriod: string currentPeriod: string,
): Promise<CardDetailData> { ): Promise<CardDetailData> {
// Build period range for last 6 months // Build period range for last 12 months
const periods: string[] = []; const periods: string[] = [];
let p = currentPeriod; let p = currentPeriod;
for (let i = 0; i < 6; i++) { for (let i = 0; i < 12; i++) {
periods.unshift(p); periods.unshift(p);
p = getPreviousPeriod(p); p = getPreviousPeriod(p);
} }
@@ -256,8 +269,8 @@ async function fetchCardDetail(
gte(lancamentos.period, startPeriod), gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod), lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA) eq(lancamentos.transactionType, DESPESA),
) ),
) )
.groupBy(lancamentos.period) .groupBy(lancamentos.period)
.orderBy(lancamentos.period); .orderBy(lancamentos.period);
@@ -286,8 +299,8 @@ async function fetchCardDetail(
eq(lancamentos.cartaoId, cardId), eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod), eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA) eq(lancamentos.transactionType, DESPESA),
) ),
) )
.groupBy(lancamentos.categoriaId); .groupBy(lancamentos.categoriaId);
@@ -312,7 +325,7 @@ async function fetchCardDetail(
const totalCategoryAmount = categoryData.reduce( const totalCategoryAmount = categoryData.reduce(
(acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)), (acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)),
0 0,
); );
const categoryBreakdown = categoryData const categoryBreakdown = categoryData
@@ -326,7 +339,8 @@ async function fetchCardDetail(
name: catInfo?.name || "Sem categoria", name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null, icon: catInfo?.icon || null,
amount, amount,
percent: totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0, percent:
totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
}; };
}) })
.sort((a, b) => b.amount - a.amount) .sort((a, b) => b.amount - a.amount)
@@ -349,8 +363,8 @@ async function fetchCardDetail(
eq(lancamentos.cartaoId, cardId), eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod), eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA) eq(lancamentos.transactionType, DESPESA),
) ),
) )
.orderBy(lancamentos.amount) .orderBy(lancamentos.amount)
.limit(10); .limit(10);
@@ -382,8 +396,8 @@ async function fetchCardDetail(
eq(faturas.userId, userId), eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId), eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod), gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod) lte(faturas.period, currentPeriod),
) ),
) )
.orderBy(faturas.period); .orderBy(faturas.period);