"use client"; import { useState, useMemo, useRef, useEffect } from "react"; import { ScrollFade } from "@/components/ScrollFade"; import { Wrench, LayoutGrid, Sparkles, ChevronRight } from "lucide-react"; import { Sparkline } from "@/components/Sparkline"; import { MONTH_NAMES } from "@/lib/data"; import type { RunningRow } from "@/lib/rows"; import { ZAR, ZAR2 } from "@/lib/utils"; /** Month total split, for the cell tooltip. Rounded — raw float sums read as * "41944.159999999996", which looks like a bug even though it is arithmetic. */ function kindTitle(byKind: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] }, i: number) { const r2 = (n: number) => n.toFixed(2).replace(/\.00$/, ""); return ( `Repairs R${r2(byKind.repairs[i])} · Purchases R${r2(byKind.purchases[i])}` + ` · Insurance R${r2(byKind.insurance[i])} · Finance R${r2(byKind.finance[i])}` ); } export function RunningView({ rows, palette, monthTotals, monthTotalsExcl, byKind, visibleMonths, }: { rows: RunningRow[]; palette: { income: string; profit: string; loss: string }; /** Per month, VAT-INCLUSIVE, all cost kinds combined. */ monthTotals: number[]; /** The same months net of VAT, shown UNDER each incl figure. */ monthTotalsExcl?: number[]; /** Per-month split behind the total, offered on hover rather than as columns. */ byKind?: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] }; /** * How many months of the year to RENDER. A month ahead cannot be calculated, only * guessed at, so the columns for it are not shown at all rather than shown empty * (client ruling, 2026-10-09). */ visibleMonths?: number; }) { const [view, setView] = useState<"cards" | "grid">("cards"); const [sort, setSort] = useState<"ytd" | "code">("ytd"); const [expanded, setExpanded] = useState(null); const sorted = useMemo(() => { return [...rows].sort((a, b) => sort === "ytd" ? b.ytd - a.ytd : a.code.localeCompare(b.code) ); }, [rows, sort]); const grandTotal = monthTotals.reduce((s, v) => s + v, 0); return ( <>
{rows.filter((r) => r.ytd > 0).length} machines with costs · sorted by {sort === "ytd" ? "total spend" : "code"}
Sort: {[ { id: "ytd", label: "Total" }, { id: "code", label: "Code" }, ].map((s) => ( ))}
{view === "cards" ? ( ) : ( s + v, 0)} /> )} ); } function CardsView({ rows, expanded, setExpanded, palette, visibleMonths, }: { rows: RunningRow[]; visibleMonths?: number; expanded: string | null; setExpanded: (id: string | null) => void; palette: { income: string; profit: string; loss: string }; }) { return (
{rows.map((r) => { const isOpen = expanded === r.id; const hasData = r.ytd > 0; return (
{isOpen && (
{/* Mobile: 3 cols (4 rows × 3 cols = 12 months). md+: 6 cols. lg+: 12 cols. */}
{r.months.slice(0, visibleMonths ?? 12).map((v, i) => (
{MONTH_NAMES[i]}
0 ? "bg-[#EDAE49]/5 text-[#EDAE49] dark:bg-[#EDAE49]/15" : "text-gray-300 dark:text-text-soft"}`}> {v > 0 ? ZAR2(v) : "—"}
))}
YTD total {ZAR(r.ytd)}
)}
); })}
); } function GridView({ rows, monthTotals, monthTotalsExcl, byKind, visibleMonths, grandTotal, grandExcl, }: { rows: RunningRow[]; monthTotals: number[]; monthTotalsExcl?: number[]; byKind?: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] }; visibleMonths?: number; grandTotal: number; grandExcl: number; }) { // Shown only when the table actually overflows — a permanent fade on a table that // cannot scroll tells the reader to drag a page with nowhere to go. const scroller = useRef(null); const [overflows, setOverflows] = useState(false); useEffect(() => { const el = scroller.current; if (!el) return; const check = () => setOverflows(el.scrollWidth > el.clientWidth + 1); check(); const ro = new ResizeObserver(check); ro.observe(el); return () => ro.disconnect(); }, [rows.length]); return (
{/* `relative` + the conditional fade give the right-edge affordance a wide table needs on a phone — but only when there is actually something to scroll to. */}
{MONTH_NAMES.slice(0, visibleMonths ?? 12).map((m) => ( ))} {rows.map((r) => ( {r.months.slice(0, visibleMonths ?? 12).map((v, i) => ( ))} ))} {/* Bottom row: the month's total on BOTH bases, incl ABOVE excl, so a reader never has to back the VAT out by hand. */} {monthTotals.slice(0, visibleMonths ?? 12).map((v, i) => ( ))}
Code Machine{m}YTD
{r.code}
{r.name}
0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}> {v > 0 ? ZAR2(v) : "—"} 0 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}> {r.ytd > 0 ? ZAR(r.ytd) : "—"}
Total incl / excl {v > 0 ? ( <> {ZAR(v)} {monthTotalsExcl && ( {ZAR(monthTotalsExcl[i] ?? 0)} )} ) : ( — )} {ZAR(grandTotal)} {grandExcl > 0 && ( {ZAR(grandExcl)} )}
{overflows && }
); }