"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 { COST_KINDS, COST_KIND_LABELS } from "@/lib/running-costs"; 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])}` ); } /** The four cost kinds. These ARE a vehicle's running cost — there is no fifth source — * so the parts always add up to the vehicle's spend and the vehicles add up to the * Overview. Showing them is what makes that checkable rather than a claim. */ const KINDS = COST_KINDS; export function RunningView({ rows, palette, monthTotals, monthTotalsExcl, byKind, visibleMonths, monthIndex, }: { 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; /** * The LIVE month, 0-based, decided on the server (lib/income-month.ts). It is the * month that is still filling up, so its figure grows as repairs and purchases are * entered against a vehicle. */ monthIndex?: 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, monthIndex, }: { rows: RunningRow[]; visibleMonths?: number; expanded: string | null; setExpanded: (id: string | null) => void; palette: { income: string; profit: string; loss: string }; monthIndex?: number; }) { const monthLabel = monthIndex != null && monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null; return (
{rows.map((r) => { const isOpen = expanded === r.id; const hasData = r.ytd > 0; const monthValue = monthIndex != null ? r.months[monthIndex] ?? 0 : 0; return (
{/* Below md the money row above is hidden, so the year and the live month get their own line — the same shape the income cards use on a phone. */}
YTD spend
{hasData ? ZAR2(r.ytd) : "—"}
{monthLabel && (
{monthLabel} so far
{monthValue > 0 ? ZAR2(monthValue) : "—"}
)}
{/* What the vehicle's spend is MADE of, on the card itself. These four parts are the whole of it, so they add up to the YTD figure above — and the vehicles add up to the Overview. A vehicle with no spend shows nothing here: "nothing recorded" is already on the card. */} {hasData && (
{KINDS.map((k, index) => ( 0 ? "text-gray-400 dark:text-text-soft" : "text-gray-300 dark:text-gray-600"}>{COST_KIND_LABELS[k]}{" "} 0 ? "font-medium tabular-nums text-gray-600 dark:text-gray-300" : "tabular-nums text-gray-300 dark:text-gray-600"}>{ZAR2(r.byKindYtd[k])} {index < KINDS.length - 1 && ·} ))}
)} {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) : "—"}
))}
{/* The four kinds, and the total they add up to. This is the check: the parts ARE the spend, so the parts and the total can never disagree. */}
{KINDS.map((k) => (
{COST_KIND_LABELS[k]}
{ZAR2(r.byKindYtd[k])}
))}
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) => ( ))} ))} {/* The four kinds, then the total they add up to. The fleet's parts are visible above its total, so "do the costs add up?" is answered on the screen instead of taken on trust. */} {byKind && KINDS.map((k) => { const cells = byKind[k].slice(0, visibleMonths ?? 12); const kindTotal = cells.reduce((s, v) => s + v, 0); return ( {cells.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) : "—"}
{COST_KIND_LABELS[k]} {v > 0 ? ZAR(v) : "—"} {kindTotal > 0 ? ZAR(kindTotal) : "—"}
Total incl / excl {v > 0 ? ( <> {ZAR(v)} {monthTotalsExcl && ( {ZAR(monthTotalsExcl[i] ?? 0)} )} ) : ( — )} {ZAR(grandTotal)} {grandExcl > 0 && ( {ZAR(grandExcl)} )}
{overflows && }
); }