import { useState, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { BarChart, Bar, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid, LabelList, } from "recharts"; import { Eye } from "lucide-react"; import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types"; import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; import ChartContextMenu from "../shared/ChartContextMenu"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); function formatMonth(month: string): string { const [year, m] = month.split("-"); const date = new Date(Number(year), Number(m) - 1); return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); } export type CategoryOverTimeChartType = "line" | "area"; interface CategoryOverTimeChartProps { data: CategoryOverTimeData; hiddenCategories: Set; onToggleHidden: (categoryName: string) => void; onShowAll: () => void; onViewDetails: (item: CategoryBreakdownItem) => void; showAmounts?: boolean; /** * Visual rendering mode. `line` (default) keeps the legacy stacked bars — * preserved for backward compatibility. `area` stacks Recharts layers * (stackId="1") showing total composition over time. Both modes share the * same palette and SVG grayscale patterns (existing signature visual). */ chartType?: CategoryOverTimeChartType; } export default function CategoryOverTimeChart({ data, hiddenCategories, onToggleHidden, onShowAll, onViewDetails, showAmounts, chartType = "line", }: CategoryOverTimeChartProps) { const { t } = useTranslation(); const hoveredRef = useRef(null); const [hoveredCategory, setHoveredCategory] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name)); const categoryEntries = visibleCategories.map((name, index) => ({ name, color: data.colors[name], index, })); const handleContextMenu = useCallback((e: React.MouseEvent) => { if (!hoveredRef.current) return; e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, name: hoveredRef.current }); }, []); if (data.data.length === 0) { return (

{t("dashboard.noData")}

); } // Shared chart configuration used by both Bar and Area variants. const patternPrefix = "cat-time"; const patternDefs = ( ({ color: c.color, index: c.index }))} /> ); const commonAxes = ( <> cadFormatter(v)} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} stroke="var(--border)" width={80} /> { if (hoveredCategory && name !== hoveredCategory) return [null, null]; return [cadFormatter(Number(value) || 0), String(name)]; }} labelFormatter={(label) => formatMonth(String(label))} contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", color: "var(--foreground)", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", }} wrapperStyle={{ zIndex: 50 }} labelStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }} filterNull /> { if (e && e.dataKey) setHoveredCategory(String(e.dataKey)); }} onMouseLeave={() => setHoveredCategory(null)} wrapperStyle={{ cursor: "pointer" }} formatter={(value) => {value}} /> ); return (
{hiddenCategories.size > 0 && (
{t("charts.hiddenCategories")}: {Array.from(hiddenCategories).map((name) => ( ))}
)}
{chartType === "area" ? ( {patternDefs} {commonAxes} {categoryEntries.map((c) => ( { hoveredRef.current = c.name; setHoveredCategory(c.name); }} onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} style={{ transition: "fill-opacity 150ms", cursor: "context-menu" }} /> ))} ) : ( {patternDefs} {commonAxes} {categoryEntries.map((c) => ( { hoveredRef.current = c.name; setHoveredCategory(c.name); }} onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} cursor="context-menu" style={{ transition: "fill-opacity 150ms" }} > {showAmounts && ( Number(v) ? cadFormatter(Number(v)) : ""} style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }} /> )} ))} )}
{contextMenu && ( onToggleHidden(contextMenu.name)} onViewDetails={() => { const color = data.colors[contextMenu.name] || "#9ca3af"; const categoryId = data.categoryIds[contextMenu.name] ?? null; onViewDetails({ category_id: categoryId, category_name: contextMenu.name, category_color: color, total: 0, }); }} onClose={() => setContextMenu(null)} /> )}
); }