mega-plant/components/ChartsGrid.tsx
Dave 2594dead84 feat: add user access and improve plant dashboard
Add PocketBase login, first-sign-in password changes, account settings,
and user activation, reactivation and deletion with self-removal guards.
Protect application pages and APIs and keep private pages out of PWA
caches.

Resolve plant locations from site assignments and improve map controls,
profit chart views, navigation and mobile table layouts.

Validation: production build and TypeScript passed; 28 unit tests passed.
Backend access integration tests and mobile verification are documented.
2026-10-06 04:02:08 +02:00

364 lines
17 KiB
TypeScript

"use client";
import { useState, useMemo, useEffect } from "react";
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale, SlidersHorizontal, Check } 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";
// Combined charts compare income and costs. Profit has its own line or bars view.
const ALL_SERIES: SeriesVisible = { income: true, fixed: true, running: true, profit: false };
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)" },
];
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" || stored === "profit-line") setViewState(stored);
} catch {}
}, []);
const setView = (v: ChartView) => {
setViewState(v);
setSeriesOpen(false);
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") {
const restored = { ...ALL_SERIES, ...parsed, profit: false };
setSeries(restored.income || restored.fixed || restored.running ? restored : ALL_SERIES);
}
}
} 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]);
// The legend follows the global view, and hidden combined series leave it too.
const legend: { color: string; label: string }[] = (
view === "profit" || view === "profit-line"
? [
{ 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.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 === "profit-line" && (
<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>
<select
aria-label="Filter machines"
value={filter}
onChange={(e) => setFilter(e.target.value as typeof filter)}
className="lg:hidden min-h-[44px] max-w-full px-3 rounded-lg bg-gray-50 dark:bg-white/5 text-sm text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-border"
>
<option value="all">All machines</option>
<option value="profitable">Profitable</option>
<option value="loss">Loss-making</option>
</select>
<div className="hidden lg: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-line", label: "Profit only", icon: TrendingUp },
{ id: "profit", label: "Profit bars", icon: Scale },
].map((v) => (
<button
key={v.id}
type="button"
onClick={() => setView(v.id as ChartView)}
aria-pressed={view === v.id}
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 for the combined Income / Fixed / Running views. */}
{(view === "lines" || view === "stack") && <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={`relative 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="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed peer"
/>
<span aria-hidden="true" className={`w-4 h-4 shrink-0 rounded border grid place-items-center peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#30638E] ${series[s.key] ? "bg-[#30638E] border-[#30638E] text-white" : "border-gray-300 dark:border-gray-500"}`}>
{series[s.key] && <Check className="w-3 h-3" />}
</span>
<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 min-h-[44px] min-w-[44px] px-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;
const profitOnly = view === "profit-line";
return (
<div key={item.id} data-machine-code={item.code} 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>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{profitOnly ? "Monthly profit" : view === "profit" ? "Monthly profit bars" : view === "stack" ? "Monthly costs" : "Monthly trend"}
</div>
{profitOnly && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-3 text-xs text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-chart-profit)]" />Profit
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-chart-loss)]" />Loss
</span>
<span>Hollow dot = no running-cost data</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>
)}
</>
);
}