Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
359 lines
17 KiB
TypeScript
359 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";
|
||
}
|