360 lines
17 KiB
TypeScript
360 lines
17 KiB
TypeScript
|
|
"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<string | null>(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 (
|
|||
|
|
<>
|
|||
|
|
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
|
|||
|
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
|||
|
|
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
|
|||
|
|
{rows.length} of {totalMachines} vehicles insured
|
|||
|
|
</span>
|
|||
|
|
<span className="hidden md:inline">· sorted by {sort === "ytd" ? "total spend" : "code"}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|||
|
|
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border">
|
|||
|
|
<button
|
|||
|
|
onClick={() => setView("cards")}
|
|||
|
|
aria-label="Cards view"
|
|||
|
|
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
|||
|
|
view === "cards"
|
|||
|
|
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
|
|||
|
|
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<Sparkles className="w-3.5 h-3.5" /> Cards
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setView("grid")}
|
|||
|
|
aria-label="Grid view"
|
|||
|
|
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
|||
|
|
view === "grid"
|
|||
|
|
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
|
|||
|
|
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<LayoutGrid className="w-3.5 h-3.5" /> Grid
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div className="hidden sm:flex items-center gap-1">
|
|||
|
|
<span className="text-xs text-gray-400 dark:text-text-soft mr-1">Sort:</span>
|
|||
|
|
{[
|
|||
|
|
{ id: "ytd", label: "Total" },
|
|||
|
|
{ id: "code", label: "Code" },
|
|||
|
|
].map((s) => (
|
|||
|
|
<button
|
|||
|
|
key={s.id}
|
|||
|
|
onClick={() => setSort(s.id as typeof sort)}
|
|||
|
|
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
|
|||
|
|
sort === s.id
|
|||
|
|
? "bg-[#EDAE49] text-white"
|
|||
|
|
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{s.label}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<div className="sm:hidden">
|
|||
|
|
<label className="sr-only" htmlFor="insurance-sort">Sort by</label>
|
|||
|
|
<select
|
|||
|
|
id="insurance-sort"
|
|||
|
|
value={sort}
|
|||
|
|
onChange={(e) => setSort(e.target.value as typeof sort)}
|
|||
|
|
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
|||
|
|
>
|
|||
|
|
<option value="ytd">Sort by Total</option>
|
|||
|
|
<option value="code">Sort by Code</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{view === "cards" ? (
|
|||
|
|
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} />
|
|||
|
|
) : (
|
|||
|
|
<GridView rows={sorted} grandTotal={grandTotal} />
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function CardsView({
|
|||
|
|
rows, expanded, setExpanded,
|
|||
|
|
}: {
|
|||
|
|
rows: InsuranceRow[];
|
|||
|
|
expanded: string | null;
|
|||
|
|
setExpanded: (id: string | null) => void;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{rows.map((r) => {
|
|||
|
|
const isOpen = expanded === r.id;
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={r.id}
|
|||
|
|
className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition"
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setExpanded(isOpen ? null : r.id)}
|
|||
|
|
className="w-full flex items-center gap-3 sm:gap-4 p-4 text-left min-h-[44px]"
|
|||
|
|
aria-expanded={isOpen}
|
|||
|
|
>
|
|||
|
|
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
|
|||
|
|
{r.code}
|
|||
|
|
</span>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white">
|
|||
|
|
<Shield className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
|
|||
|
|
<span className="truncate">{r.name}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{r.problems.length > 0 && (
|
|||
|
|
<TriangleAlert
|
|||
|
|
className="w-4 h-4 text-[#D1495B] dark:text-[#D94F60] shrink-0"
|
|||
|
|
aria-label="Rate history problem"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<div className="hidden sm:block shrink-0">
|
|||
|
|
<Sparkline data={r.months.map((v) => v ?? 0)} color={ACCENT} />
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right shrink-0 w-32 hidden md:block">
|
|||
|
|
<div className="text-[12px] text-gray-400 uppercase tracking-wider">YTD premium</div>
|
|||
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|||
|
|
{r.ytd > 0 ? ZAR2(r.ytd) : "—"}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-[#EDAE49]/10 text-[#EDAE49] dark:bg-[#EDAE49]/20 whitespace-nowrap">
|
|||
|
|
{r.currentRate !== null ? `${ZAR2(r.currentRate)}/mo` : "no rate"}
|
|||
|
|
</div>
|
|||
|
|
<ChevronRight
|
|||
|
|
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
|||
|
|
/>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{isOpen && (
|
|||
|
|
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
|
|||
|
|
{/* Mobile: 3 cols (4 rows × 3 = 12 months). md+: 6. lg+: 12. */}
|
|||
|
|
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-xs mb-5">
|
|||
|
|
{r.months.map((v, i) => (
|
|||
|
|
<div key={i} className="text-center">
|
|||
|
|
<div className="text-gray-400 dark:text-text-soft text-xs mb-1">{MONTH_NAMES[i]}</div>
|
|||
|
|
{/* A null month is unknown, not free — hence the dashed box. */}
|
|||
|
|
<div
|
|||
|
|
className={`py-1.5 px-0.5 rounded tabular-nums ${
|
|||
|
|
v === null
|
|||
|
|
? "border border-dashed border-gray-300 dark:border-white/15 text-gray-400 dark:text-text-soft"
|
|||
|
|
: "bg-[#EDAE49]/5 text-[#EDAE49] dark:bg-[#EDAE49]/15"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{v === null ? "no rate" : ZAR2(v)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="text-xs uppercase tracking-wider text-gray-400 mb-2">
|
|||
|
|
Rate history <span className="normal-case tracking-normal text-gray-400 dark:text-text-soft">· {VAT_NOTE}</span>
|
|||
|
|
</div>
|
|||
|
|
<ul className="space-y-1.5">
|
|||
|
|
{r.periods.map((p, i) => (
|
|||
|
|
<li key={i} className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
|||
|
|
<span className="text-gray-600 dark:text-gray-300">{periodLabel(p)}</span>
|
|||
|
|
<span className="tabular-nums font-semibold text-[#EDAE49]">
|
|||
|
|
{ZAR2(p.rate)}
|
|||
|
|
<span className="font-normal text-gray-400 dark:text-text-soft"> / mo</span>
|
|||
|
|
</span>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
|
|||
|
|
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs flex flex-wrap items-baseline gap-x-3">
|
|||
|
|
<span className="text-gray-400 uppercase tracking-wider">YTD total</span>
|
|||
|
|
<span className="font-semibold tabular-nums text-[#EDAE49]">{ZAR(r.ytd)}</span>
|
|||
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|||
|
|
across {r.coveredMonths} of 12 months
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function GridView({
|
|||
|
|
rows, grandTotal,
|
|||
|
|
}: {
|
|||
|
|
rows: InsuranceRow[];
|
|||
|
|
grandTotal: number;
|
|||
|
|
}) {
|
|||
|
|
const scroller = useRef<HTMLDivElement>(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 (
|
|||
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
|
|||
|
|
<div className="relative">
|
|||
|
|
<div ref={scroller} className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
|||
|
|
<th className="text-left py-3 px-6 font-medium sticky left-0 z-10 bg-gray-50 dark:bg-surface-muted">Code</th>
|
|||
|
|
<th className="text-left py-3 px-3 font-medium">Vehicle</th>
|
|||
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">
|
|||
|
|
Current<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">{VAT_NOTE}</span>
|
|||
|
|
</th>
|
|||
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">
|
|||
|
|
Previous
|
|||
|
|
</th>
|
|||
|
|
<th className="text-right py-3 px-6 font-medium whitespace-nowrap">
|
|||
|
|
Year total<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">{r_coveredLabel(rows)}</span>
|
|||
|
|
</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{rows.map((r) => {
|
|||
|
|
const prev = r.periods[1];
|
|||
|
|
return (
|
|||
|
|
<tr key={r.id} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
|
|||
|
|
{/* OPAQUE: the row tint is translucent, so a transparent sticky
|
|||
|
|
column would let the other columns bleed through it on scroll. */}
|
|||
|
|
<td className="py-2.5 px-6 sticky left-0 z-10 bg-white dark:bg-surface">
|
|||
|
|
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
|
|||
|
|
{r.code}
|
|||
|
|
</span>
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Shield className="w-3 h-3 text-[#EDAE49] shrink-0" />
|
|||
|
|
<span className="truncate">{r.name}</span>
|
|||
|
|
{r.problems.length > 0 && (
|
|||
|
|
<TriangleAlert className="w-3.5 h-3.5 text-[#D1495B] dark:text-[#D94F60] shrink-0" aria-label="Rate history problem" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td
|
|||
|
|
className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap text-[#EDAE49]"
|
|||
|
|
title={r.periods[0] ? periodLabel(r.periods[0]) : undefined}
|
|||
|
|
>
|
|||
|
|
{r.currentRate !== null ? ZAR2(r.currentRate) : "—"}
|
|||
|
|
</td>
|
|||
|
|
<td
|
|||
|
|
className="py-2.5 px-3 text-right tabular-nums whitespace-nowrap text-gray-500 dark:text-gray-400"
|
|||
|
|
title={prev ? periodLabel(prev) : "No earlier rate period on record"}
|
|||
|
|
>
|
|||
|
|
{prev ? ZAR2(prev.rate) : <span className="text-gray-300 dark:text-text-soft">—</span>}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap text-[#003D5B] dark:text-white">
|
|||
|
|
{ZAR(r.ytd)}
|
|||
|
|
{r.coveredMonths < 12 && (
|
|||
|
|
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
|||
|
|
{r.coveredMonths} of 12 mo
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
<tr className="bg-gray-50 dark:bg-surface-muted border-t-2 border-[#EDAE49]/40">
|
|||
|
|
<td className="py-3 px-6 sticky left-0 z-10 bg-gray-50 dark:bg-surface-muted" />
|
|||
|
|
<td className="py-3 pr-3 font-semibold text-right text-[#003D5B] dark:text-white">
|
|||
|
|
Total · {rows.length} vehicles
|
|||
|
|
</td>
|
|||
|
|
<td className="py-3 px-3 text-right tabular-nums font-semibold whitespace-nowrap text-[#EDAE49]">
|
|||
|
|
{ZAR(rows.reduce((s, r) => s + (r.currentRate ?? 0), 0))}
|
|||
|
|
</td>
|
|||
|
|
{/* Only the re-rated vehicles carry a previous rate, so this is a
|
|||
|
|
partial figure — labelled as such rather than implying the fleet. */}
|
|||
|
|
<td className="py-3 px-3 text-right tabular-nums font-semibold whitespace-nowrap text-gray-500 dark:text-gray-400">
|
|||
|
|
{reRated ? ZAR(previousTotal) : "—"}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-3 px-6 text-right tabular-nums font-bold text-base text-[#EDAE49] whitespace-nowrap">
|
|||
|
|
{ZAR(grandTotal)}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
{overflows && <ScrollFade />}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** "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";
|
|||
|
|
}
|