mega-plant/components/InsuranceView.tsx
David f85b9378a2 Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
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>
2026-10-09 01:06:27 +02:00

359 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
}