Add LynkedUp Pro dashboard (dark + light) from Figma
- 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>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { TrendingUp, Cloud, MoreHorizontal, ArrowUpRight } from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { Topbar } from "./topbar";
|
||||
import { FigIcon } from "./figicon";
|
||||
import { BarsChart, Spark, Donut, Gauge, Progress, HBar } from "./charts";
|
||||
import "../../app/dashboard/dashboard.css";
|
||||
|
||||
const C = {
|
||||
orange: "#fda913", orange2: "#fd6d13", yellow: "#ffd60a",
|
||||
cyan: "#09b9c6", green: "#14bc83", blue: "#285ef0", purple: "#9036e9", red: "#f0563f",
|
||||
};
|
||||
|
||||
const rnd = (v: number, p = 0.12) => Math.max(1, Math.round(v * (1 + (Math.random() * 2 - 1) * p)));
|
||||
const arr = (base: number[], p = 0.14) => base.map((v) => rnd(v, p));
|
||||
|
||||
export function Dashboard() {
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [active, setActive] = useState("dashboard");
|
||||
|
||||
useEffect(() => {
|
||||
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
|
||||
}, []);
|
||||
function toggle() {
|
||||
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
|
||||
}
|
||||
|
||||
// ---- live data ----
|
||||
const [pnl, setPnl] = useState({
|
||||
revenue: [4, 6, 3, 7, 5, 6, 4], gross: [5, 3, 6, 4, 7, 5, 6],
|
||||
comm: [3, 5, 4, 6, 5, 7, 4], net: [4, 6, 5, 3, 6, 4, 7],
|
||||
});
|
||||
const [rev, setRev] = useState([5, 7, 4, 8, 6, 9, 16, 7, 5, 8]);
|
||||
const [sparks, setSparks] = useState({
|
||||
revenue: [4, 7, 5, 9, 6, 10, 8], leads: [6, 4, 8, 5, 9, 6, 10], meets: [5, 8, 6, 9, 7, 10, 8],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setPnl((p) => ({ revenue: arr(p.revenue), gross: arr(p.gross), comm: arr(p.comm), net: arr(p.net) }));
|
||||
setRev((r) => r.map((v, i) => (i === 6 ? 16 : rnd(v, 0.18))));
|
||||
setSparks((s) => ({ revenue: arr(s.revenue), leads: arr(s.leads), meets: arr(s.meets) }));
|
||||
}, 2600);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
const yk = ["8k", "6k", "4k", "2k", "0"];
|
||||
|
||||
return (
|
||||
<div className="dash-root" data-theme={theme}>
|
||||
<Sidebar active={active} onSelect={setActive} />
|
||||
<div className="dash-main">
|
||||
<Topbar theme={theme} onToggle={toggle} />
|
||||
<div className="dash-content">
|
||||
|
||||
{/* ===== Statistics ===== */}
|
||||
<h2 className="sec-title">Statistics</h2>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1.55fr 1fr", marginBottom: 16 }}>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr" }}>
|
||||
<BigStat icon={<FigIcon name="i_revenue" size={18} />} ic={C.orange} tag="GROSS PROFIT $114K" value="$626K" label="Revenue" spark={sparks.revenue} sc={C.orange} />
|
||||
<BigStat icon={<FigIcon name="i_hotleads" size={18} />} ic={C.red} tag="REQUIRES ACTION" value="20k" label="Hot Leads" spark={sparks.leads} sc={C.purple} />
|
||||
<BigStat icon={<FigIcon name="i_meets" size={18} />} ic={C.cyan} tag="NEXT 7 DAYS" value="70k" label="Upcoming Meets" spark={sparks.meets} sc={C.cyan} />
|
||||
</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr" }}>
|
||||
<MiniStat icon={<FigIcon name="i_collected" size={16} />} ic={C.cyan} value="$311.3k" label="Collected to Date" sub="32 payments" pill="pill-cyan" />
|
||||
<MiniStat icon={<FigIcon name="i_ar" size={16} />} ic={C.orange} value="$325.9k" label="Outstanding AR" sub="25 pending" pill="pill-orange" />
|
||||
<MiniStat icon={<FigIcon name="i_payouts" size={16} />} ic={C.purple} value="$203.7k" label="Pending Payouts" sub="20 pending" pill="pill-blue" />
|
||||
<MiniStat icon={<FigIcon name="i_vendor" size={16} />} ic={C.blue} value="$432.4k" label="YTD Vendor Spend" sub="57 invoices" pill="pill-blue" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Pipeline / Win / Storm ===== */}
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr", marginBottom: 16 }}>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Open Pipeline</h3><span className="pill pill-orange">27 leads</span></div>
|
||||
<div className="big-val" style={{ marginBottom: 14 }}>$396K</div>
|
||||
{[["New", 6], ["Contacted", 7], ["Appointment", 7], ["Estimate", 3]].map(([l, n]) => (
|
||||
<div className="pl-row" key={l as string}>
|
||||
<span className="lab">{l}</span>
|
||||
<HBar value={n as number} max={8} color={C.orange} />
|
||||
<span className="n">{n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Win Rate</h3><TrendingUp size={16} color={C.green} /></div>
|
||||
<div className="big-val" style={{ marginBottom: 14 }}>94%</div>
|
||||
<Progress value={94} color={C.cyan} />
|
||||
<div className="row" style={{ gap: 16, marginTop: 14, flexWrap: "wrap" }}>
|
||||
<Legend dot={C.cyan} text="17 Won" />
|
||||
<Legend dot="var(--muted)" text="18 Decided" />
|
||||
<Legend dot={C.red} text="1 Lost" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Storm-Attributed Revenue</h3><span className="pill pill-blue">19 Storm Leads</span></div>
|
||||
<div className="big-val" style={{ marginBottom: 14 }}>24%</div>
|
||||
<Progress value={24} color={C.blue} />
|
||||
<div className="lbl" style={{ marginTop: 12 }}>$56K of $626K</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Roof / Risk / Weather ===== */}
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1.15fr 1.25fr", marginBottom: 16 }}>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><div><h3>Roof Condition</h3><div className="card-sub">Territory health snapshot</div></div></div>
|
||||
<div className="row" style={{ justifyContent: "center", margin: "6px 0 10px" }}>
|
||||
<Donut segments={[{ value: 30, color: C.orange }, { value: 14, color: C.cyan }]} total="44" label="Total" />
|
||||
</div>
|
||||
<div className="row" style={{ gap: 18, justifyContent: "center" }}>
|
||||
<Legend dot={C.orange} text="Good" />
|
||||
<Legend dot={C.cyan} text="Bad" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dcard">
|
||||
<div className="card-h"><div><h3>Risk Index</h3><div className="card-sub">Storm Impact Probability</div></div></div>
|
||||
<div className="row" style={{ justifyContent: "center", margin: "4px 0" }}>
|
||||
<Gauge value={51} max={100} color={C.blue} size={170} label="51" sub="of 100" />
|
||||
</div>
|
||||
<div className="row between" style={{ marginTop: 6 }}>
|
||||
<Readout big="10 mph" small="Wind Exp." />
|
||||
<Readout big="52%" small="Vulnerable" color={C.orange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dcard">
|
||||
<div className="row between">
|
||||
<div>
|
||||
<div className="row gap-2"><Cloud size={20} color={C.cyan} /><span className="big-val">80°F</span></div>
|
||||
<div className="lbl">Plano, US</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="card-sub">Updated: 02:48 PM</div>
|
||||
<div className="row gap-3" style={{ marginTop: 8, justifyContent: "flex-end" }}>
|
||||
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: "var(--muted)", display: "inline-flex" }}><FigIcon name="i_wind" size={14} /></span> 10 mph</span>
|
||||
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: C.blue, display: "inline-flex" }}><FigIcon name="i_humidity" size={14} /></span> 82%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wx-hours">
|
||||
{[["12 PM", 94], ["5 PM", 79], ["8 PM", 82], ["10 PM", 90], ["1 AM", 92], ["5 AM", 79]].map(([t, te]) => (
|
||||
<div className="wx-hr" key={t as string}>
|
||||
<div className="t">{t}</div>
|
||||
<Cloud size={16} color="var(--faint)" style={{ display: "block", margin: "6px auto 0" }} />
|
||||
<div className="te">{te}°</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== P&L Summary ===== */}
|
||||
<h2 className="sec-title">P&L Summary</h2>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr", marginBottom: 16 }}>
|
||||
<PnlCard title="Revenue" value="$626K" labels={days} yk={yk}
|
||||
series={[{ color: C.orange, values: pnl.revenue }, { color: C.orange2, values: pnl.gross }]} />
|
||||
<PnlCard title="Gross Profit" value="$114K" labels={days} yk={yk}
|
||||
series={[{ color: C.cyan, values: pnl.gross }, { color: C.orange, values: pnl.revenue }]} />
|
||||
<PnlCard title="Commissions" value="$63K" labels={days} yk={yk}
|
||||
series={[{ color: C.blue, values: pnl.comm }, { color: "#6f9bff", values: pnl.net }]} />
|
||||
<PnlCard title="Net Profit" value="$51K" labels={days} yk={yk}
|
||||
series={[{ color: C.orange, values: pnl.net }, { color: C.blue, values: pnl.comm }]} />
|
||||
</div>
|
||||
|
||||
{/* ===== Revenue Potential / Top Reps ===== */}
|
||||
<div className="grid" style={{ gridTemplateColumns: "1.7fr 1fr" }}>
|
||||
<div className="dcard">
|
||||
<div className="card-h">
|
||||
<div><h3>Revenue Potential</h3><div className="card-sub">By neighborhood rating</div></div>
|
||||
<span className="pill pill-orange"><ArrowUpRight size={13} /> live</span>
|
||||
</div>
|
||||
<BarsChart
|
||||
labels={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
series={[{ color: C.orange, values: rev }]}
|
||||
max={18} highlight={6} height={210} barWidth={26}
|
||||
yTicks={["$240k", "$180k", "$120k", "$60k", "0"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Top Sales Reps</h3><button className="link-btn">View All</button></div>
|
||||
{[["Hannah Reyes", "hannah@lynkeduppro.com", C.orange], ["Cody Tatum", "cody@lynkeduppro.com", C.cyan], ["Travis Boone", "travis@lynkeduppro.com", C.blue]].map(([nm, em, col], i) => (
|
||||
<div className="rep-row" key={nm as string}>
|
||||
<span className="rk">0{i + 1}</span>
|
||||
<span className="av" style={{ background: col as string, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{(nm as string).split(" ").map((x) => x[0]).join("")}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="nm">{nm}</div>
|
||||
<div className="em">{em}</div>
|
||||
</div>
|
||||
<span className="amt">$112,500</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- sub components ---- */
|
||||
function BigStat({ icon, ic, tag, value, label, spark, sc }: { icon: React.ReactNode; ic: string; tag: string; value: string; label: string; spark: number[]; sc: string }) {
|
||||
return (
|
||||
<div className="dcard">
|
||||
<div className="row between" style={{ marginBottom: 14 }}>
|
||||
<span className="stat-ic" style={{ background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
|
||||
<span className="card-sub" style={{ fontWeight: 600, letterSpacing: "0.04em" }}>{tag}</span>
|
||||
</div>
|
||||
<div className="row between" style={{ alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<div className="big-val">{value}</div>
|
||||
<div className="lbl">{label}</div>
|
||||
</div>
|
||||
<Spark values={spark} color={sc} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ icon, ic, value, label, sub, pill }: { icon: React.ReactNode; ic: string; value: string; label: string; sub: string; pill: string }) {
|
||||
return (
|
||||
<div className="mini-card">
|
||||
<div className="top">
|
||||
<span className="stat-ic" style={{ width: 30, height: 30, background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
|
||||
<span className={`pill ${pill}`}>{sub}</span>
|
||||
</div>
|
||||
<div className="mid-val" style={{ marginTop: 12 }}>{value}</div>
|
||||
<div className="lbl">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PnlCard({ title, value, labels, yk, series }: { title: string; value: string; labels: string[]; yk: string[]; series: { color: string; values: number[] }[] }) {
|
||||
return (
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>{title}</h3><MoreHorizontal size={16} color="var(--faint)" /></div>
|
||||
<div className="mid-val" style={{ marginBottom: 14 }}>{value}</div>
|
||||
<BarsChart labels={labels} series={series} max={9} yTicks={yk} height={120} barWidth={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ dot, text }: { dot: string; text: string }) {
|
||||
return <span className="row gap-2" style={{ fontSize: 12, color: "var(--muted)" }}><span className="ldot" style={{ background: dot }} /> {text}</span>;
|
||||
}
|
||||
|
||||
function Readout({ big, small, color }: { big: string; small: string; color?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: color || "var(--text)" }}>{big}</div>
|
||||
<div className="lbl">{small}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { figmaIcons } from "./figma-icons";
|
||||
|
||||
/** Renders a Figma-exported icon (recolored to currentColor) or a fallback. */
|
||||
export function FigIcon({ name, size = 18 }: { name: string; size?: number }) {
|
||||
const svg = figmaIcons[name];
|
||||
if (!svg) return <Sparkles size={size} />;
|
||||
return (
|
||||
<span
|
||||
className="fig-ic"
|
||||
style={{ width: size, height: size }}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { FigIcon } from "./figicon";
|
||||
|
||||
type Item = { key: string; label: string };
|
||||
|
||||
const GROUPS: { title: string; items: Item[] }[] = [
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ key: "dashboard", label: "Dashboard" },
|
||||
{ key: "owners", label: "Owners Box" },
|
||||
{ key: "projects", label: "Projects" },
|
||||
{ key: "leads", label: "Leads" },
|
||||
{ key: "verify", label: "Lead Verification" },
|
||||
{ key: "pipeline", label: "Pipeline" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ key: "dispatch", label: "LynkDispatch" },
|
||||
{ key: "storm", label: "Storm Intel" },
|
||||
{ key: "territory", label: "Territory Map" },
|
||||
{ key: "procanvas", label: "ProCanvas" },
|
||||
{ key: "estimates", label: "Estimates" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
items: [
|
||||
{ key: "schedule", label: "Team Schedule" },
|
||||
{ key: "leaderboard", label: "Leaderboard" },
|
||||
{ key: "subtasks", label: "Subcontractor Tasks" },
|
||||
{ key: "people", label: "People" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const EXTRA: Item[] = [
|
||||
{ key: "settings", label: "Org Settings" },
|
||||
{ key: "ai", label: "AI Assistant" },
|
||||
{ key: "home", label: "Home" },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
|
||||
return (
|
||||
<aside className="dash-sidebar">
|
||||
<div className="dash-brand">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/image/logo.png" alt="LynkedUp Pro" />
|
||||
<span className="bk">LynkedUp Pro</span>
|
||||
</div>
|
||||
|
||||
<nav className="dash-nav">
|
||||
{GROUPS.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
<div className="nav-group">{g.title}</div>
|
||||
{g.items.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: 8 }} />
|
||||
{EXTRA.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
|
||||
</nav>
|
||||
|
||||
<div className="sb-foot">
|
||||
<div className="sb-user">
|
||||
<span className="av" style={{ background: "linear-gradient(135deg,#285ef0,#09b9c6)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="nm">Justin Johnson</div>
|
||||
<div className="rl">Owner</div>
|
||||
</div>
|
||||
<ChevronsUpDown size={15} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBtn({ it, active, onSelect }: { it: Item; active: string; onSelect: (k: string) => void }) {
|
||||
return (
|
||||
<button className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
|
||||
<FigIcon name={it.key} size={18} />
|
||||
{it.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, ChevronDown } from "lucide-react";
|
||||
import { FigIcon } from "./figicon";
|
||||
|
||||
export function Topbar({ theme, onToggle }: { theme: "dark" | "light"; onToggle: () => void }) {
|
||||
return (
|
||||
<header className="dash-topbar">
|
||||
<div className="dash-title">
|
||||
<h1>Dashboard Overview</h1>
|
||||
<p>Welcome back, here's what's happening with your territory</p>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="ic-btn" aria-label="Search"><FigIcon name="i_search" size={18} /></button>
|
||||
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
|
||||
{theme === "dark" ? <FigIcon name="i_theme" size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
|
||||
<FigIcon name="i_bell" size={18} />
|
||||
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||
</button>
|
||||
<button className="top-user">
|
||||
<span className="av" style={{ background: "linear-gradient(135deg,#fda913,#fd6d13)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span>
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<div className="nm">Justin Johnson</div>
|
||||
<div className="rl">Owner</div>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user