mega-plant/components/InsuranceView.tsx

360 lines
17 KiB
TypeScript
Raw Normal View History

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-08 23:06:27 +00:00
"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";
}