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