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)
This commit is contained in:
2026-03-16 13:02:30 +01:00
parent 448195016f
commit 971c5c7cbe
2 changed files with 175 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils"
interface ChartEmptyStateProps {
message: string
className?: string
}
export function ChartEmptyState({ message, className }: ChartEmptyStateProps) {
return (
<div
className={cn(
"flex min-h-[250px] w-full items-center justify-center rounded-lg border border-dashed border-muted-foreground/20 bg-muted/30",
className
)}
>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
)
}

View File

@@ -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 (
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={(outerRadius ?? 85) + 8}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
)
}
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 <ChartEmptyState message={emptyMessage} />
}
// 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 (
<div>
<ChartContainer config={displayConfig} className="min-h-[250px] w-full">
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="type"
formatter={(value) =>
formatCurrency(Number(value), currency)
}
/>
}
/>
<Pie
data={displayData}
dataKey="value"
nameKey="type"
innerRadius={60}
outerRadius={85}
cx="50%"
cy="50%"
activeIndex={isAllZero ? undefined : activeIndex}
activeShape={isAllZero ? undefined : renderActiveShape}
onMouseEnter={
isAllZero ? undefined : (_, index) => setActiveIndex(index)
}
onMouseLeave={
isAllZero ? undefined : () => setActiveIndex(-1)
}
>
{displayData.map((entry) => (
<Cell
key={entry.type}
fill={
isAllZero
? "var(--color-muted)"
: `var(--color-${entry.type}-fill)`
}
/>
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan className="fill-foreground text-xl font-bold">
{formatCurrency(totalExpenses, currency)}
</tspan>
</text>
)
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
{/* Custom legend below the donut */}
{!isAllZero && data.length > 0 && (
<ul className="mt-2 space-y-1">
{data.map((entry) => (
<li
key={entry.type}
className="flex items-center gap-2 text-sm"
>
<span
className="inline-block size-3 shrink-0 rounded-full"
style={{
backgroundColor: `var(--color-${entry.type}-fill)`,
}}
/>
<span className="text-muted-foreground">{entry.label}</span>
<span className="ml-auto font-medium tabular-nums">
{formatCurrency(entry.value, currency)}
</span>
</li>
))}
</ul>
)}
</div>
)
}