Files
openmonetis/components/relatorios/category-report-chart.tsx
Felipe Coutinho 1b90be6b54 feat: topbar de navegação como experimento de UI (v1.7.0)
- Substitui header fixo por topbar com backdrop blur e navegação agrupada em 5 seções
- Adiciona FerramentasDropdown consolidando calculadora e modo privacidade
- NotificationBell expandida com orçamentos e pré-lançamentos
- Remove logout-button, header-dashboard e privacy-mode-toggle como componentes separados
- Logo refatorado com variante compact; topbar com links em lowercase
- Adiciona dependência radix-ui ^1.4.3
- Atualiza CHANGELOG para v1.7.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 15:43:14 +00:00

233 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { RiPieChartLine } from "@remixicon/react";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
type TooltipProps,
XAxis,
} from "recharts";
import { EmptyState } from "@/components/empty-state";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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";
function AreaTooltip({ active, payload, label }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null;
const items = payload
.filter((entry) => Number(entry.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
if (items.length === 0) return null;
return (
<div className="min-w-[210px] rounded-lg border border-border/50 bg-background px-3 py-2.5 shadow-xl">
<p className="mb-2.5 border-b border-border/50 pb-1.5 text-xs font-semibold text-foreground">
{label}
</p>
<div className="space-y-1.5">
{items.map((entry) => (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-6"
>
<div className="flex min-w-0 items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
{currencyFormatter.format(Number(entry.value))}
</span>
</div>
))}
</div>
</div>
);
}
interface CategoryReportChartProps {
data: CategoryChartData;
}
const LIMIT_OPTIONS = [
{ value: "5", label: "Top 5" },
{ value: "10", label: "Top 10" },
{ value: "15", label: "Top 15" },
] as const;
const MAX_CATEGORIES = 15;
export function CategoryReportChart({ data }: CategoryReportChartProps) {
const { chartData, categories } = data;
const [limit, setLimit] = React.useState("10");
const { topCategories, filteredChartData } = React.useMemo(() => {
const limitNum = Math.min(Number(limit), MAX_CATEGORIES);
const categoriesWithTotal = categories.map((category) => ({
...category,
total: chartData.reduce((sum, point) => {
const v = point[category.name];
return sum + (typeof v === "number" ? v : 0);
}, 0),
}));
const sorted = categoriesWithTotal
.sort((a, b) => b.total - a.total)
.slice(0, limitNum);
const filtered = chartData.map((point) => {
const result: { month: string; [key: string]: number | string } = {
month: point.month,
};
for (const cat of sorted) {
result[cat.name] = (point[cat.name] as number) ?? 0;
}
return result;
});
return { topCategories: sorted, filteredChartData: filtered };
}, [categories, chartData, limit]);
const chartConfig = React.useMemo<ChartConfig>(() => {
const config: ChartConfig = {};
for (let i = 0; i < topCategories.length; i++) {
const cat = topCategories[i];
config[cat.name] = {
label: cat.name,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
};
}
return config;
}, [topCategories]);
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"
/>
);
}
const firstMonth = chartData[0]?.month ?? "";
const lastMonth = chartData[chartData.length - 1]?.month ?? "";
const periodLabel =
firstMonth === lastMonth ? firstMonth : `${firstMonth} ${lastMonth}`;
return (
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Evolução por Categoria</CardTitle>
<CardDescription>{periodLabel}</CardDescription>
</div>
<Select value={limit} onValueChange={setLimit}>
<SelectTrigger
className="hidden w-[130px] rounded-lg sm:ml-auto sm:flex"
aria-label="Número de categorias"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
{LIMIT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="rounded-lg"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[300px] w-full"
>
<AreaChart data={filteredChartData}>
<defs>
{topCategories.map((cat, index) => {
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
return (
<linearGradient
key={cat.id}
id={`fill-${cat.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
</linearGradient>
);
})}
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip cursor={false} content={<AreaTooltip />} />
{topCategories.map((cat, index) => (
<Area
key={cat.id}
dataKey={cat.name}
type="natural"
fill={`url(#fill-${cat.id})`}
stroke={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
strokeWidth={1.5}
stackId="a"
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}