WFDASH-WEBAPP/src/components/charts/donut-chart.tsx
SJCoetz bb4233a9f2 feat(dashboard): add multi-theme system and supporting improvements
Add complete multi-theme dashboard system including:
- 8 selectable themes with persistent per-browser preferences
- Theme bootstrap script to prevent FOUC on page load
- 12+ reusable chart components (area, bar, donut, radial gauge, etc)
- Custom checkbox UI component
- New settings page for appearance customization

Other changes:
- Optimize supplier map API payload with getMappableSuppliers helper
- Add aria-label attributes to back buttons on detail pages for better accessibility
- Update next.config.ts to include new local development IP 192.168.1.128
- Update site manifest with all new favicon and PWA assets
- Fix styling and layout spacing in rewrite quote modal
2026-06-26 17:59:40 +02:00

162 lines
4.4 KiB
TypeScript

"use client";
import { useId } from "react";
import { withAlpha } from "./palette";
export type DonutBucket = {
label: string;
value: number;
color: string;
};
export function DonutChart({
buckets,
total,
centerLabel,
centerSubLabel,
size = 180,
}: {
buckets: DonutBucket[];
total: number;
centerLabel: string;
centerSubLabel?: string;
size?: number;
}) {
const cx = size / 2;
const cy = size / 2;
const r = size * 0.36;
const stroke = size * 0.13;
const circumference = 2 * Math.PI * r;
const gradId = useId();
if (total === 0 || buckets.length === 0) {
return (
<div
className="flex items-center justify-center text-sm text-muted-foreground"
style={{ height: size }}
>
No data yet.
</div>
);
}
// Build segments with running cumulative offset via reduce (lint-friendly)
const segments = buckets.reduce<
Array<DonutBucket & { dash: number; offset: number; fraction: number }>
>((acc, b) => {
const priorTotal = acc.reduce((s, x) => s + x.fraction, 0);
const fraction = b.value / total;
acc.push({
...b,
dash: fraction * circumference,
offset: -priorTotal * circumference,
fraction,
});
return acc;
}, []);
return (
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="relative shrink-0">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<defs>
{segments.map((s, i) => (
<filter
key={i}
id={`glow-${gradId}-${i}`}
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
))}
</defs>
{/* Background ring */}
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={withAlpha("#94A3B8", 14)}
strokeWidth={stroke}
/>
{segments.map((s, i) => (
<circle
key={i}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={s.color}
strokeWidth={stroke}
strokeDasharray={`${s.dash} ${circumference}`}
strokeDashoffset={s.offset}
transform={`rotate(-90 ${cx} ${cy})`}
strokeLinecap="butt"
filter={`url(#glow-${gradId}-${i})`}
/>
))}
{/* Center label */}
<text
x={cx}
y={centerSubLabel ? cy - 6 : cy + 6}
textAnchor="middle"
style={{
fontSize: size * 0.16,
fontFamily: "var(--font-heading)",
fontWeight: 700,
fill: "var(--foreground)",
}}
>
{centerLabel}
</text>
{centerSubLabel && (
<text
x={cx}
y={cy + 14}
textAnchor="middle"
style={{
fontSize: size * 0.07,
letterSpacing: 1.4,
textTransform: "uppercase",
fill: "var(--muted-foreground)",
fontWeight: 600,
}}
>
{centerSubLabel}
</text>
)}
</svg>
</div>
{/* Legend */}
<div className="flex-1 w-full space-y-1.5 text-xs">
{segments.map((s, i) => (
<div key={i} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{
background: s.color,
boxShadow: `0 0 8px ${withAlpha(s.color, 50)}`,
}}
/>
<span className="truncate">{s.label}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-semibold tabular-nums">{s.value}</span>
<span className="text-muted-foreground tabular-nums w-9 text-right">
{Math.round(s.fraction * 100)}%
</span>
</div>
</div>
))}
</div>
</div>
);
}