mega-plant/components/ChartsGrid.tsx
David 681225e51d Charts: a profit line that changes colour, and a Series toggle
Profit was a single always-teal line, so a month that lost money looked
like a month that made a little. It is now drawn per sign: one <Line> per
same-sign run, plus a gradient <Line> across each crossing. Recharts draws
a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate
series are the only way. Every variant is a key on the ONE shared dataset
rather than a sliced `data` prop, because Recharts builds a categorical axis
by concatenating every series' data and a short slice re-adds the month
labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content`
component to fold those per-run keys back into a single profit row.

Two honesty rules: profit stays null in a month with no income figure
(treating it as 0 drew a green dot for a month that was never billed), and
a month with no running-cost row gets a hollow dot, because its profit is
optimistic.

The toolbar's Series button toggles which lines a card draws, remembered in
localStorage. A hidden series takes its legend entry and its Y axis with it,
so there is never an axis for a line that is not drawn, and the last visible
series cannot be switched off.

Palette: Running moves off red to the project's existing brand purple,
because red is now reserved for a loss and a red Running line next to a red
loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit`
are separate tokens on purpose — loss used to read the Running token, so
recolouring Running silently dragged the red with it.

/charts alone now reads profit including running cost, so the card badge,
the profit line and the Profit bars view cannot disagree. Scoped to the
page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 09:20:16 +02:00

337 lines
16 KiB
TypeScript

"use client";
import { useState, useMemo, useEffect } from "react";
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale, SlidersHorizontal } from "lucide-react";
import { ZAR2 } from "@/lib/utils";
import { MachineLineChart, type ChartView, type SeriesVisible } from "@/components/MachineLineChart";
// /charts reads profit INCLUDING running cost, so the line, the card badge and the
// Profit view all sit on one basis. lib/profit.ts keeps its workbook default.
const CHART_PROFIT_INCLUDE_RUNNING = true;
type Item = {
id: string;
code: string;
name: string;
bucket: { income: (number | null)[]; fixed: (number | null)[]; running: (number | null)[] };
totalIncome: number;
totalProfit: number;
};
const VIEW_KEY = "mp:chartsView";
const SERIES_KEY = "mp:chartsSeries";
// Which lines a card draws. The Profit toggle also controls the loss colour,
// because red is a sign of the profit line, not a series of its own.
const ALL_SERIES: SeriesVisible = { income: true, fixed: true, running: true, profit: true };
const SERIES_LIST: { key: keyof SeriesVisible; label: string; color: string }[] = [
{ key: "income", label: "Income", color: "var(--color-chart-income)" },
{ key: "fixed", label: "Fixed", color: "var(--color-chart-fixed)" },
{ key: "running", label: "Running", color: "var(--color-chart-running)" },
{ key: "profit", label: "Profit", color: "var(--color-chart-profit)" },
];
export function ChartsGrid({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"all" | "profitable" | "loss">("all");
const [view, setViewState] = useState<ChartView>("lines");
// Persist view in localStorage (Part A.5)
useEffect(() => {
try {
const stored = localStorage.getItem(VIEW_KEY);
if (stored === "lines" || stored === "stack" || stored === "profit") setViewState(stored);
} catch {}
}, []);
const setView = (v: ChartView) => {
setViewState(v);
try { localStorage.setItem(VIEW_KEY, v); } catch {}
};
const [series, setSeries] = useState<SeriesVisible>(ALL_SERIES);
const [seriesOpen, setSeriesOpen] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(SERIES_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<SeriesVisible>;
if (parsed && typeof parsed === "object") setSeries({ ...ALL_SERIES, ...parsed });
}
} catch {}
}, []);
const toggleSeries = (k: keyof SeriesVisible) => {
setSeries((prev) => {
const next = { ...prev, [k]: !prev[k] };
// An empty chart reads as broken, so the last visible line stays on.
if (Object.values(next).every((v) => !v)) return prev;
try { localStorage.setItem(SERIES_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
const resetSeries = () => {
setSeries(ALL_SERIES);
try { localStorage.setItem(SERIES_KEY, JSON.stringify(ALL_SERIES)); } catch {}
};
// Close the popover on Escape or an outside click.
useEffect(() => {
if (!seriesOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSeriesOpen(false); };
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement)?.closest?.("[data-series-popover]")) setSeriesOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
};
}, [seriesOpen]);
const seriesOn = SERIES_LIST.filter((s) => series[s.key]).length;
const filtered = useMemo(() => {
let list = items;
if (query.trim()) {
const q = query.toLowerCase();
list = list.filter((i) => i.code.toLowerCase().includes(q) || i.name.toLowerCase().includes(q));
}
if (filter === "profitable") list = list.filter((i) => i.totalProfit > 0);
if (filter === "loss") list = list.filter((i) => i.totalProfit < 0);
list = [...list].sort((a, b) => b.totalIncome - a.totalIncome);
return list;
}, [items, query, filter]);
const stats = useMemo(() => {
const profitable = items.filter((i) => i.totalProfit > 0).length;
const totalIncome = items.reduce((s, i) => s + i.totalIncome, 0);
return { profitable, total: items.length, totalIncome };
}, [items]);
// Legend swatches per view (Part A.5). Profit/Loss only appear where they are
// actually drawn — the Lines view is the only one carrying the profit line, so
// the Cost-stack view must not advertise it. A hidden series leaves the legend
// too, so the key always matches the card.
const legend: { color: string; label: string }[] = (
view === "profit"
? [
{ color: "var(--color-chart-profit)", label: "Profit" },
{ color: "var(--color-chart-loss)", label: "Loss" },
]
: view === "lines"
? [
series.income ? { color: "var(--color-chart-income)", label: "Income" } : null,
series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null,
series.running ? { color: "var(--color-chart-running)", label: "Running" } : null,
series.profit ? { color: "var(--color-chart-profit)", label: "Profit" } : null,
series.profit ? { color: "var(--color-chart-loss)", label: "Loss" } : null,
]
: [
series.income ? { color: "var(--color-chart-income)", label: "Income" } : null,
series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null,
series.running ? { color: "var(--color-chart-running)", label: "Running" } : null,
]
).filter((x): x is { color: string; label: string } => x !== null);
return (
<>
{/* Round 4 Part B.1/B.6: title + subtitle stack above legend on mobile; legend wraps below */}
<div className="flex flex-col gap-3 mb-6">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<LineChartIcon className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Machine Charts</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income
</p>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap text-xs">
{legend.map((l) => (
<div key={l.label} className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ background: l.color }} />
<span className="text-gray-500 dark:text-gray-400">{l.label}</span>
</div>
))}
{view === "lines" && series.profit && (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-white border-2 border-[#7A9E06]" />
<span className="text-gray-500 dark:text-gray-400">hollow = no running-cost data</span>
</div>
)}
</div>
</div>
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
<div className="relative bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by code or name…"
className="w-full min-h-[44px] pl-9 pr-3 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
/>
</div>
<div className="flex items-center gap-1">
{[
{ id: "all", label: "All" },
{ id: "profitable", label: "Profitable" },
{ id: "loss", label: "Loss-making" },
].map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id as typeof filter)}
className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition ${
filter === f.id
? "bg-[#30638E] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-1 sm:pl-3 sm:border-l sm:border-gray-200 sm:dark:border-white/10">
<span className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-text-soft mr-1 hidden sm:inline">View</span>
{[
{ id: "lines", label: "Lines", icon: LineChartIcon },
{ id: "stack", label: "Cost stack", icon: BarChart3 },
{ id: "profit", label: "Profit", icon: Scale },
].map((v) => (
<button
key={v.id}
onClick={() => setView(v.id as ChartView)}
className={`min-h-[44px] px-3 text-xs font-medium rounded transition flex items-center gap-1 ${
view === v.id
? "bg-[#003D5B] text-white"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
<v.icon className="w-3 h-3" />
{v.label}
</button>
))}
</div>
{/* Series filter — a small block of checkboxes so a card can be read with
the profit line off, or with only income and profit on, etc. */}
<div className="relative" data-series-popover>
<button
onClick={() => setSeriesOpen((o) => !o)}
aria-expanded={seriesOpen}
aria-haspopup="true"
className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition flex items-center gap-1.5 ${
seriesOn === SERIES_LIST.length
? "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
: "bg-[#30638E]/10 text-[#30638E] dark:text-[#7FB3D5]"
}`}
>
<SlidersHorizontal className="w-3.5 h-3.5" />
<span>Series</span>
<span className="tabular-nums">
{seriesOn}/{SERIES_LIST.length}
</span>
</button>
{seriesOpen && (
<div className="absolute left-0 sm:left-auto sm:right-0 mt-2 z-30 w-60 max-w-[calc(100vw-2rem)] rounded-2xl border border-gray-100 dark:border-border bg-white dark:bg-surface shadow-lg p-2">
<div className="px-2 pt-1 pb-2 text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft">
Show on chart
</div>
{SERIES_LIST.map((s) => {
// Only the LAST remaining series is locked — the guard exists to stop
// an empty chart, not to trap the reader on one series. Disabling every
// box here meant the only way to add a line back was "Show all".
const locked = seriesOn === 1 && series[s.key];
return (
<label
key={s.key}
className={`flex items-center gap-3 min-h-[44px] px-2 rounded-lg text-sm ${
locked ? "opacity-50" : "cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5"
}`}
>
<input
type="checkbox"
checked={series[s.key]}
disabled={locked}
onChange={() => toggleSeries(s.key)}
className="w-4 h-4 accent-[#30638E] cursor-pointer disabled:cursor-not-allowed"
/>
<span className="w-2.5 h-2.5 rounded-full" style={{ background: s.color }} />
<span className="text-gray-700 dark:text-gray-200">{s.label}</span>
</label>
);
})}
<button
onClick={resetSeries}
disabled={seriesOn === SERIES_LIST.length}
className="w-full min-h-[44px] mt-1 rounded-lg text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 disabled:opacity-40 disabled:hover:bg-transparent"
>
Show all
</button>
</div>
)}
</div>
</div>
{filtered.length === 0 ? (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 text-center">
<Search className="w-8 h-8 text-gray-300 dark:text-text-soft mx-auto mb-3" />
<div className="text-sm text-gray-500 dark:text-gray-400">No machines match "{query}"</div>
<button onClick={() => setQuery("")} className="mt-3 text-xs text-accent-ink hover:underline">
Clear search
</button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filtered.map((item) => {
// Was computeProfit({ income: totalIncome, fixed: 0, running: 0 }) — which is
// just totalIncome, so every card showed a green "profit" equal to its income.
// totalProfit is the real YTD figure from buildIncomeRows, on the same basis
// the profit line uses below.
const profit = item.totalProfit;
const positiveProfit = profit >= 0;
return (
<div key={item.id} className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm hover:shadow-md transition">
<div className="flex items-start justify-between mb-3">
<div>
<span className="font-mono text-xs px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold">
{item.code}
</span>
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mt-1.5">{item.name}</h3>
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${
positiveProfit ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
}`}>
{positiveProfit ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{positiveProfit ? "+" : ""}{ZAR2(profit)}
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3">
<span>YTD income</span>
<span className="text-[#003D5B] dark:text-white font-semibold">{ZAR2(item.totalIncome)}</span>
</div>
<MachineLineChart
income={item.bucket.income}
fixed={item.bucket.fixed}
running={item.bucket.running}
view={view}
includeRunningCost={CHART_PROFIT_INCLUDE_RUNNING}
series={series}
/>
</div>
);
})}
</div>
)}
</>
);
}