The client asked for two things on the running-cost pages: drop the two prose notes, and show the year-to-date figure next to the total for the current month. - /running loses the "Shown to Jan, Feb..." and "A re-cut of costs..." paragraphs. The tiles carry the year and the month instead. - /running shows "Nov so far" beside "YTD running cost", and the "made of" line is printed for the month as well as for the year, so the month's total is explained on the page and not only in the grid. - The cards follow the income cards: YTD spend | Nov so far side by side from md, the month on its own line below md, and the sparkline yields its width below xl so the vehicle NAME is never truncated to pay for a decorative trend. Below sm the name wraps instead of being cut short. - /running/insurance shows the premium in force this month, and its year-to-date figure now stops at the months that have happened. December is a rate on file, not money spent, so including it made this page a full year while the Overview showed eleven months - the same cost kind reading as two different numbers on two screens. - /running/financing shows "Committed for Nov" beside a new year-to-date instalment figure. One rule, three screens. elapsedMonths() in lib/income-month.ts counts the months that have happened and is the same clock the income pages use, and financeByAsset() / fleetFinanceByMonth() in lib/running-costs.ts are the only finance rule the Overview and the financing register both read. The Overview's insurance and finance year-to-date figures now equal the insurance and financing pages' figures exactly. lib/running-month.test.ts asserts it, including the insurance cross-check between the register and the Overview. A settled agreement still contributes nothing to any month - the instalments TLB06 was paying in January-October are not in the Overview's finance figure. The client confirmed the financing is correct as it stands, so that is left alone and recorded in AGENTS.md rather than quietly changed.
365 lines
17 KiB
TypeScript
365 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,
|
||
monthIndex,
|
||
throughMonth,
|
||
}: {
|
||
rows: InsuranceRow[];
|
||
totalMachines: number;
|
||
/** The live month, 0-based, decided on the server. */
|
||
monthIndex?: number;
|
||
/** The last month of the year-to-date window, 1-based. */
|
||
throughMonth?: number;
|
||
}) {
|
||
const monthLabel = monthIndex != null && monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null;
|
||
const windowLabel = `${MONTH_NAMES[0]} – ${MONTH_NAMES[(throughMonth ?? 12) - 1]}`;
|
||
// 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} monthLabel={monthLabel} windowLabel={windowLabel} />
|
||
) : (
|
||
<GridView rows={sorted} grandTotal={grandTotal} windowLabel={windowLabel} throughMonth={throughMonth} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CardsView({
|
||
rows, expanded, setExpanded, monthLabel, windowLabel,
|
||
}: {
|
||
rows: InsuranceRow[];
|
||
expanded: string | null;
|
||
setExpanded: (id: string | null) => void;
|
||
monthLabel: string | null;
|
||
windowLabel: string;
|
||
}) {
|
||
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.chargedMonths} months · {windowLabel} 2026
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GridView({
|
||
rows, grandTotal, windowLabel, throughMonth,
|
||
}: {
|
||
rows: InsuranceRow[];
|
||
grandTotal: number;
|
||
windowLabel: string;
|
||
throughMonth?: 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">
|
||
YTD<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">{windowLabel}</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.chargedMonths < (throughMonth ?? 12) && (
|
||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||
{r.chargedMonths} of {throughMonth ?? 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>
|
||
);
|
||
}
|