From 971c5c7cbe948626646e17e1adbd76f94ba99c41 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 13:02:30 +0100 Subject: [PATCH] feat(02-02): create ExpenseDonutChart with center label and active hover - Donut chart with innerRadius/outerRadius, center total label via formatCurrency - Active sector expansion on hover via activeShape + Sector - Custom legend below chart with color dots and formatted amounts - CSS variable fills via ChartConfig (no hardcoded hex values) - Empty state: ChartEmptyState placeholder when no data - Zero-amount state: neutral muted ring with $0 center label - ChartEmptyState shared component created (Rule 3: blocking dependency from Plan 01) --- .../dashboard/charts/ChartEmptyState.tsx | 19 +++ .../dashboard/charts/ExpenseDonutChart.tsx | 156 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/components/dashboard/charts/ChartEmptyState.tsx create mode 100644 src/components/dashboard/charts/ExpenseDonutChart.tsx diff --git a/src/components/dashboard/charts/ChartEmptyState.tsx b/src/components/dashboard/charts/ChartEmptyState.tsx new file mode 100644 index 0000000..5df74a1 --- /dev/null +++ b/src/components/dashboard/charts/ChartEmptyState.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils" + +interface ChartEmptyStateProps { + message: string + className?: string +} + +export function ChartEmptyState({ message, className }: ChartEmptyStateProps) { + return ( +
+

{message}

+
+ ) +} diff --git a/src/components/dashboard/charts/ExpenseDonutChart.tsx b/src/components/dashboard/charts/ExpenseDonutChart.tsx new file mode 100644 index 0000000..dc55ba7 --- /dev/null +++ b/src/components/dashboard/charts/ExpenseDonutChart.tsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from "react" +import { PieChart, Pie, Cell, Sector, Label } from "recharts" +import type { PieSectorDataItem } from "recharts/types/polar/Pie" +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import type { ChartConfig } from "@/components/ui/chart" +import { ChartEmptyState } from "./ChartEmptyState" +import { formatCurrency } from "@/lib/format" + +interface ExpenseDonutChartProps { + data: Array<{ type: string; value: number; label: string }> + totalExpenses: number + currency: string + emptyMessage: string +} + +function renderActiveShape(props: PieSectorDataItem) { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = + props + return ( + + ) +} + +export function ExpenseDonutChart({ + data, + totalExpenses, + currency, + emptyMessage, +}: ExpenseDonutChartProps) { + const [activeIndex, setActiveIndex] = useState(-1) + + const chartConfig = useMemo(() => { + const config: ChartConfig = {} + for (const entry of data) { + config[entry.type] = { + label: entry.label, + color: `var(--color-${entry.type}-fill)`, + } + } + return config + }, [data]) + + // No data at all: show empty state placeholder + if (data.length === 0 && totalExpenses === 0) { + return + } + + // Zero-amount state: budget exists but all actuals are zero + const isAllZero = totalExpenses === 0 + const displayData = isAllZero + ? [{ type: "empty", value: 1, label: "" }] + : data + const displayConfig: ChartConfig = isAllZero + ? { empty: { label: "", color: "var(--color-muted)" } } + : chartConfig + + return ( +
+ + + + formatCurrency(Number(value), currency) + } + /> + } + /> + setActiveIndex(index) + } + onMouseLeave={ + isAllZero ? undefined : () => setActiveIndex(-1) + } + > + {displayData.map((entry) => ( + + ))} + + + + + {/* Custom legend below the donut */} + {!isAllZero && data.length > 0 && ( +
    + {data.map((entry) => ( +
  • + + {entry.label} + + {formatCurrency(entry.value, currency)} + +
  • + ))} +
+ )} +
+ ) +}