5ff655d786
- Full dashboard at /dashboard matching the Figma Linkedup-Web frame - Dark/light theme toggle (persisted to localStorage) - Live animated charts: stat sparkbars, P&L grouped bars, donut, gauge, pipeline bars, progress, revenue-potential - Sidebar, topbar, stat cards, weather, top sales reps - 29 icons exported from Figma (currentColor) + lucide fallbacks - Fix: bar charts had zero height (percentage in indefinite flex parent) - Fix: removed content max-width that left a right-side gap Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
/* ---- vertical bar chart (HTML/CSS, animated heights) ---- */
|
|
export function BarsChart({
|
|
labels,
|
|
series,
|
|
max,
|
|
yTicks,
|
|
height = 132,
|
|
highlight,
|
|
barWidth = 7,
|
|
}: {
|
|
labels: string[];
|
|
series: { color: string; values: number[] }[];
|
|
max: number;
|
|
yTicks?: string[];
|
|
height?: number;
|
|
highlight?: number; // single-series: index to highlight, others muted
|
|
barWidth?: number;
|
|
}) {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setOn(true), 40);
|
|
return () => clearTimeout(t);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="chart" style={{ height }}>
|
|
{yTicks && (
|
|
<div className="chart-y">
|
|
{yTicks.map((t) => <span key={t}>{t}</span>)}
|
|
</div>
|
|
)}
|
|
<div className="chart-plot">
|
|
{labels.map((lab, i) => (
|
|
<div className="bar-group" key={i}>
|
|
<div className="bars">
|
|
{series.map((s, si) => {
|
|
const muted = highlight !== undefined && i !== highlight;
|
|
return (
|
|
<span
|
|
key={si}
|
|
className="vbar"
|
|
style={{
|
|
width: barWidth,
|
|
height: on ? `${Math.max(2, (s.values[i] / max) * 100)}%` : "0%",
|
|
background: muted ? "var(--track)" : s.color,
|
|
transitionDelay: `${i * 35 + si * 20}ms`,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<span className="bar-x">{lab}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- mini sparkline bars (stat cards) ---- */
|
|
export function Spark({ values, color, height = 40, width = 8 }: { values: number[]; color: string; height?: number; width?: number }) {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => { const t = setTimeout(() => setOn(true), 40); return () => clearTimeout(t); }, []);
|
|
const max = Math.max(...values, 1);
|
|
return (
|
|
<div className="spark" style={{ height }}>
|
|
{values.map((v, i) => (
|
|
<span key={i} style={{ width, height: on ? `${(v / max) * 100}%` : "0%", background: color, transitionDelay: `${i * 30}ms` }} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- donut chart ---- */
|
|
export function Donut({
|
|
segments, total, label, size = 150, thickness = 18,
|
|
}: {
|
|
segments: { value: number; color: string }[];
|
|
total: string;
|
|
label: string;
|
|
size?: number;
|
|
thickness?: number;
|
|
}) {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
|
|
const r = (size - thickness) / 2;
|
|
const c = 2 * Math.PI * r;
|
|
const sum = segments.reduce((a, s) => a + s.value, 0) || 1;
|
|
let offset = 0;
|
|
return (
|
|
<div className="donut" style={{ width: size, height: size }}>
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--track)" strokeWidth={thickness} />
|
|
{segments.map((s, i) => {
|
|
const frac = s.value / sum;
|
|
const len = on ? frac * c : 0;
|
|
const dash = `${len} ${c - len}`;
|
|
const el = (
|
|
<circle
|
|
key={i}
|
|
cx={size / 2} cy={size / 2} r={r} fill="none"
|
|
stroke={s.color} strokeWidth={thickness} strokeLinecap="round"
|
|
strokeDasharray={dash}
|
|
strokeDashoffset={-offset * c}
|
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
style={{ transition: "stroke-dasharray .9s cubic-bezier(.2,.7,.2,1)" }}
|
|
/>
|
|
);
|
|
offset += frac;
|
|
return el;
|
|
})}
|
|
</svg>
|
|
<div className="donut-c">
|
|
<div className="dv">{total}</div>
|
|
<div className="dl">{label}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- semicircle gauge ---- */
|
|
export function Gauge({
|
|
value, max = 100, color, size = 150, label, sub,
|
|
}: {
|
|
value: number; max?: number; color: string; size?: number; label?: string; sub?: string;
|
|
}) {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
|
|
const sw = 14;
|
|
const r = (size - sw) / 2;
|
|
const cx = size / 2, cy = size / 2;
|
|
const semi = Math.PI * r; // half circumference
|
|
const frac = Math.min(1, value / max);
|
|
const len = on ? frac * semi : 0;
|
|
return (
|
|
<div className="gauge" style={{ width: size, height: size / 2 + 16 }}>
|
|
<svg width={size} height={size / 2 + 16} viewBox={`0 0 ${size} ${size / 2 + 16}`}>
|
|
<path d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`} fill="none" stroke="var(--track)" strokeWidth={sw} strokeLinecap="round" />
|
|
<path
|
|
d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`}
|
|
fill="none" stroke={color} strokeWidth={sw} strokeLinecap="round"
|
|
strokeDasharray={`${len} ${semi}`}
|
|
style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.7,.2,1)" }}
|
|
/>
|
|
</svg>
|
|
{(label || sub) && (
|
|
<div className="gauge-c">
|
|
{label && <div className="gv">{label}</div>}
|
|
{sub && <div className="gl">{sub}</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- progress bar ---- */
|
|
export function Progress({ value, color, track }: { value: number; color: string; track?: boolean }) {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
|
|
return (
|
|
<div className="bar">
|
|
<span style={{ width: on ? `${value}%` : "0%", background: color, transition: "width .9s cubic-bezier(.2,.7,.2,1)" }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- horizontal segmented bar (pipeline) ---- */
|
|
export function HBar({ value, max, color }: { value: number; max: number; color: string }) {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
|
|
return (
|
|
<div className="bar" style={{ height: 9 }}>
|
|
<span style={{ width: on ? `${(value / max) * 100}%` : "0%", background: color, transition: "width .8s cubic-bezier(.2,.7,.2,1)" }} />
|
|
</div>
|
|
);
|
|
}
|