Files
openmonetis/components/relatorios/category-report-chart.tsx
Felipe Coutinho f50261208a feat(v1.4.0): design system semântico, correções de revalidação e melhorias de UX
- Adicionar tokens semânticos de estado (success, warning, info) no globals.css
- Migrar ~60+ componentes de cores hardcoded do Tailwind para tokens semânticos
- Unificar 3 arrays duplicados de cores de categorias em importação única
- Corrigir widgets de boleto/fatura que não atualizavam após pagamento
  (actions de fatura e antecipação não invalidavam cache do dashboard)
- Corrigir scroll em listas Popover+Command (modal prop)
- Adicionar link "detalhes" no card de orçamento para página da categoria
- Adicionar indicadores de tendência coloridos nos cards de métricas
- Estender cores de chart de 6 para 10
- Normalizar dark mode e remover tokens não utilizados

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:14:59 +00:00

206 lines
5.8 KiB
TypeScript

"use client";
import { RiPieChartLine } from "@remixicon/react";
import { useMemo } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
interface CategoryReportChartProps {
data: CategoryChartData;
}
const CHART_COLORS = CATEGORY_COLORS;
const MAX_CATEGORIES_IN_CHART = 15;
export function CategoryReportChart({ data }: CategoryReportChartProps) {
const { chartData, categories } = data;
// Check if there's no data
if (categories.length === 0 || chartData.length === 0) {
return (
<EmptyState
title="Nenhum dado disponível"
description="Não há transações no período selecionado para as categorias filtradas."
media={<RiPieChartLine className="h-12 w-12" />}
mediaVariant="icon"
/>
);
}
// Get top 10 categories by total spending
const { topCategories, filteredChartData } = useMemo(() => {
// Calculate total for each category across all periods
const categoriesWithTotal = categories.map((category) => {
const total = chartData.reduce((sum, dataPoint) => {
const value = dataPoint[category.name];
return sum + (typeof value === "number" ? value : 0);
}, 0);
return { ...category, total };
});
// Sort by total (descending) and take top 10
const sorted = categoriesWithTotal
.sort((a, b) => b.total - a.total)
.slice(0, MAX_CATEGORIES_IN_CHART);
// Filter chartData to include only top categories
const _topCategoryNames = new Set(sorted.map((cat) => cat.name));
const filtered = chartData.map((dataPoint) => {
const filteredPoint: { month: string; [key: string]: number | string } = {
month: dataPoint.month,
};
// Only include data for top categories
for (const cat of sorted) {
if (dataPoint[cat.name] !== undefined) {
filteredPoint[cat.name] = dataPoint[cat.name];
}
}
return filteredPoint;
});
return { topCategories: sorted, filteredChartData: filtered };
}, [categories, chartData]);
return (
<Card>
<CardHeader>
<CardTitle>
Evolução por Categoria - Top {topCategories.length}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={filteredChartData}>
<defs>
{topCategories.map((category, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<linearGradient
key={category.id}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
);
})}
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="month"
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(value) => {
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}k`;
}
return value.toString();
}}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<div className="mb-2 font-semibold">
{payload[0]?.payload?.month}
</div>
<div className="space-y-1">
{payload.map((entry, index) => {
if (entry.dataKey === "month") return null;
return (
<div
key={index}
className="flex items-center justify-between gap-4 text-sm"
>
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">
{entry.name}
</span>
</div>
<span className="font-medium">
{currencyFormatter.format(
Number(entry.value) || 0,
)}
</span>
</div>
);
})}
</div>
</div>
);
}}
/>
{topCategories.map((category, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={color}
strokeWidth={2}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="mt-4 flex flex-wrap gap-4">
{topCategories.map((category, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<div key={category.id} className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-sm text-muted-foreground">
{category.name}
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}