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
162 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|