"use client"; import { useState, useMemo, useRef, useEffect } from "react"; import { Shield, LayoutGrid, Sparkles, ChevronRight, TriangleAlert } from "lucide-react"; import { Sparkline } from "@/components/Sparkline"; import { ScrollFade } from "@/components/ScrollFade"; import { MONTH_NAMES } from "@/lib/data"; import type { InsuranceRow } from "@/lib/rows"; import type { RatePeriod } from "@/lib/insurance"; import { ZAR, ZAR2 } from "@/lib/utils"; import { VAT_NOTE } from "@/lib/insurance-rate-policy"; // Accent stays in the Running Costs family (#EDAE49) on purpose — Insurance is a // sub-page of /running, not a sibling of it. const ACCENT = "#EDAE49"; /** "Apr 2026 – current" for an open period, "Jan 2026 – Mar 2026" for a closed one. */ function periodLabel(p: RatePeriod): string { const from = `${MONTH_NAMES[p.from - 1]} 2026`; return p.to === 0 ? `${from} → current` : `${from} → ${MONTH_NAMES[p.to - 1]} 2026`; } /** * Sibling of `RunningView`: same toolbar, same card shape, same Cards/Grid pair, so * the two pages under Running Costs feel like one family. * * It is a client component for the view/sort/expand state only — every figure is * resolved on the server from the rate periods. Mobile checklist (AGENTS.md) is why * nothing drops below `text-[12px]` or `min-h-[44px]`, and why the wide grid table * scrolls inside its own container. */ export function InsuranceView({ rows, totalMachines, }: { rows: InsuranceRow[]; totalMachines: number; }) { // GRID opens first (client request, 2026-10-09). With Current / Previous / Year // total in five columns, the table reads as a register at a glance — where the card // list buried exactly those three figures behind a click, and a card per vehicle // only earns its keep when the month-by-month strip is what you came for. // Cards stay one tap away for anyone who wants the sparkline. const [view, setView] = useState<"cards" | "grid">("grid"); const [sort, setSort] = useState<"ytd" | "code">("ytd"); const [expanded, setExpanded] = useState(null); const sorted = useMemo( () => [...rows].sort((a, b) => (sort === "ytd" ? b.ytd - a.ytd : a.code.localeCompare(b.code))), [rows, sort], ); const grandTotal = rows.reduce((s, r) => s + r.ytd, 0); return ( <>
{rows.length} of {totalMachines} vehicles insured · sorted by {sort === "ytd" ? "total spend" : "code"}
Sort: {[ { id: "ytd", label: "Total" }, { id: "code", label: "Code" }, ].map((s) => ( ))}
{view === "cards" ? ( ) : ( )} ); } function CardsView({ rows, expanded, setExpanded, }: { rows: InsuranceRow[]; expanded: string | null; setExpanded: (id: string | null) => void; }) { return (
{rows.map((r) => { const isOpen = expanded === r.id; return (
{isOpen && (
{/* Mobile: 3 cols (4 rows × 3 = 12 months). md+: 6. lg+: 12. */}
{r.months.map((v, i) => (
{MONTH_NAMES[i]}
{/* A null month is unknown, not free — hence the dashed box. */}
{v === null ? "no rate" : ZAR2(v)}
))}
Rate history · {VAT_NOTE}
    {r.periods.map((p, i) => (
  • {periodLabel(p)} {ZAR2(p.rate)} / mo
  • ))}
YTD total {ZAR(r.ytd)} across {r.coveredMonths} of 12 months
)}
); })}
); } function GridView({ rows, grandTotal, }: { rows: InsuranceRow[]; grandTotal: number; }) { const scroller = useRef(null); const [overflows, setOverflows] = useState(false); // The scroll affordance is shown ONLY when the table actually overflows. Five // narrow columns fit a phone outright, and a permanent right-edge fade on a table // that cannot scroll is a lie about the layout — it tells the reader to drag a // page that has nowhere to go. 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]); // A vehicle with no earlier period has NO previous rate — that is different from a // previous rate of R0, so the cell is a dash and says why on hover. const previousTotal = rows.reduce((s, r) => s + (r.periods[1]?.rate ?? 0), 0); const reRated = rows.filter((r) => r.periods.length > 1).length; return (
{rows.map((r) => { const prev = r.periods[1]; return ( {/* OPAQUE: the row tint is translucent, so a transparent sticky column would let the other columns bleed through it on scroll. */} ); })} {/* Only the re-rated vehicles carry a previous rate, so this is a partial figure — labelled as such rather than implying the fleet. */}
Code Vehicle Current{VAT_NOTE} Previous Year total{r_coveredLabel(rows)}
{r.code}
{r.name} {r.problems.length > 0 && ( )}
{r.currentRate !== null ? ZAR2(r.currentRate) : "—"} {prev ? ZAR2(prev.rate) : —} {ZAR(r.ytd)} {r.coveredMonths < 12 && ( {r.coveredMonths} of 12 mo )}
Total · {rows.length} vehicles {ZAR(rows.reduce((s, r) => s + (r.currentRate ?? 0), 0))} {reRated ? ZAR(previousTotal) : "—"} {ZAR(grandTotal)}
{overflows && }
); } /** "12 mo covered" / "1 gap" caption under the Year total header. */ function r_coveredLabel(rows: InsuranceRow[]): string { const gaps = rows.filter((r) => r.coveredMonths < 12).length; return gaps ? `${gaps} with a gap` : "12 mo covered"; }