"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("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(ALL_SERIES); const [seriesOpen, setSeriesOpen] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(SERIES_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; 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 */}

Machine Charts

{stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income

{legend.map((l) => (
{l.label}
))} {view === "profit-line" && (
hollow = no running-cost data
)}
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
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" />
{[ { id: "all", label: "All" }, { id: "profitable", label: "Profitable" }, { id: "loss", label: "Loss-making" }, ].map((f) => ( ))}
View {[ { 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) => ( ))}
{/* Series filter for the combined Income / Fixed / Running views. */} {(view === "lines" || view === "stack") &&
{seriesOpen && (
Show on chart
{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 ( ); })}
)}
}
{filtered.length === 0 ? (
No machines match "{query}"
) : (
{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 (
{item.code}

{item.name}

{positiveProfit ? : } {positiveProfit ? "+" : ""}{ZAR2(profit)}
YTD income {ZAR2(item.totalIncome)}
{profitOnly ? "Monthly profit" : view === "profit" ? "Monthly profit bars" : view === "stack" ? "Monthly costs" : "Monthly trend"}
{profitOnly && (
Profit Loss Hollow dot = no running-cost data
)}
); })}
)} ); }