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:
19
src/components/dashboard/charts/ChartEmptyState.tsx
Normal file
19
src/components/dashboard/charts/ChartEmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
src/components/dashboard/charts/ExpenseDonutChart.tsx
Normal file
156
src/components/dashboard/charts/ExpenseDonutChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user