mega-plant/components/FinancingView.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

405 lines
No EOL
17 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Landmark, TriangleAlert, Check, CalendarClock, X } from "lucide-react";
import { ScrollFade } from "@/components/ScrollFade";
import type { FinancingRow } from "@/lib/rows";
import { formatMonth } from "@/lib/financing";
import { VAT_NOTE } from "@/lib/insurance-rate-policy";
import { ZAR, ZAR2 } from "@/lib/utils";
const ACCENT = "#30638E"; // Bastion blue — financing reads as a commitment, not a running cost
/** `YYYY-MM` for a native month input, or "" when the date was never entered. */
function toInputValue(year: number, month: number): string {
return year > 0 && month >= 1 && month <= 12 ? `${year}-${String(month).padStart(2, "0")}` : "";
}
function fromInputValue(v: string): { year: number; month: number } {
if (!v) return { year: 0, month: 0 };
const [y, m] = v.split("-").map(Number);
return { year: y || 0, month: m || 0 };
}
export function FinancingView({ rows }: { rows: FinancingRow[] }) {
const router = useRouter();
const [sort, setSort] = useState<"installment" | "code" | "outstanding">("installment");
const [pending, startTransition] = useTransition();
const [savedId, setSavedId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const scroller = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
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]);
// Saves on CHANGE, never per keystroke — a month picker commits at once, and a PATCH
// per character would both hammer PocketBase and revalidate the page mid-typing.
async function save(id: string, patch: Record<string, unknown>) {
startTransition(async () => {
try {
const res = await fetch(`/api/financing/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const body = await res.json();
if (body.ok) {
setSavedId(id);
setTimeout(() => setSavedId((s) => (s === id ? null : s)), 1600);
// The dates change what the outstanding figures MEAN, and the maths lives on
// the server. refresh() re-renders the server components with the new row; a
// full reload would throw away the scroll position and the sort chosen.
router.refresh();
} else {
alert(body.error || "Could not save");
}
} catch {
alert("Could not save — check your connection");
}
});
}
const editingRow = editingId ? rows.find((r) => r.id === editingId) ?? null : null;
const sorted = [...rows].sort((a, b) => {
if (sort === "code") return a.code.localeCompare(b.code);
if (sort === "outstanding") return (b.calc.outstanding ?? -1) - (a.calc.outstanding ?? -1);
return b.installment - a.installment;
});
// What leaves the bank each month. A settled agreement has stopped, so it is
// excluded — otherwise the headline figure would still carry machines that are
// already paid off.
const totalInstalment = rows.reduce((s, r) => s + (r.isSettled ? 0 : r.installment), 0);
const liveCount = rows.filter((r) => !r.isSettled).length;
const knownOutstanding = rows.reduce((s, r) => s + (r.calc.outstanding ?? 0), 0);
const withKnownOwing = rows.filter((r) => r.calc.outstanding !== null).length;
const attention = rows.filter((r) => r.calc.needsAttention).length;
return (
<div>
<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-[#30638E]/10 text-[#30638E] font-medium">
{liveCount} financed
</span>
{attention > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#D1495B]/10 text-[#D1495B] dark:text-[#D94F60] font-medium">
{attention} need attention
</span>
)}
</div>
<div className="sm:hidden">
<label className="sr-only" htmlFor="fin-sort">Sort by</label>
<select
id="fin-sort"
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
<option value="installment">Sort by Instalment</option>
<option value="outstanding">Sort by Still Owing</option>
<option value="code">Sort by Code</option>
</select>
</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: "installment", label: "Instalment" },
{ id: "outstanding", label: "Owing" },
{ id: "code", label: "Code" },
] as const).map((s) => (
<button
key={s.id}
onClick={() => setSort(s.id)}
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
sort === s.id
? "bg-[#30638E] text-white"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{s.label}
</button>
))}
</div>
</div>
{/* The editor lives OUTSIDE the table on purpose. Inside a colSpan row it
inherits the table's full width (~966px), which on a 375px phone puts the
Save button ~600px off-screen — unusable. As its own card it is always as
wide as the page. */}
{editingRow && (
<DateEditor
row={editingRow}
pending={pending}
onSave={(patch) => save(editingRow.id, patch)}
onCancel={() => setEditingId(null)}
/>
)}
<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 dark:bg-surface-muted">
<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">
Instalment
<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">per month · {VAT_NOTE}</span>
</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Months left</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Still owing</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Paid off by</th>
<th className="text-center py-3 px-3 font-medium whitespace-nowrap">Financing</th>
<th className="text-center py-3 px-6 font-medium whitespace-nowrap">Dates</th>
</tr>
</thead>
<tbody>
{sorted.map((r) => {
const endLabel = formatMonth(r.endYear, r.endMonth);
const isEditing = editingId === r.id;
return (
<Row
key={r.id}
r={r}
endLabel={endLabel}
isEditing={isEditing}
pending={pending}
saved={savedId === r.id}
onEdit={() => setEditingId(isEditing ? null : r.id)}
onToggle={() => save(r.id, { isSettled: !r.isSettled })}
/>
);
})}
<tr className="bg-gray-50 dark:bg-surface-muted border-t-2 border-[#30638E]/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 · {liveCount} live of {rows.length}
</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold whitespace-nowrap text-[#30638E]">
{ZAR(totalInstalment)}
</td>
<td className="py-3 px-3 text-right text-[12px] text-gray-400 dark:text-text-soft whitespace-nowrap">
{withKnownOwing} of {rows.length} dated
</td>
<td className="py-3 px-3 text-right tabular-nums font-bold text-base whitespace-nowrap text-[#30638E]">
{withKnownOwing ? ZAR(knownOutstanding) : "—"}
</td>
<td colSpan={3} />
</tr>
</tbody>
</table>
</div>
{overflows && <ScrollFade />}
</div>
</div>
</div>
);
}
function Row({
r, endLabel, isEditing, pending, saved, onEdit, onToggle,
}: {
r: FinancingRow;
endLabel: string | null;
isEditing: boolean;
pending: boolean;
saved: boolean;
onEdit: () => void;
onToggle: () => void;
}) {
return (
<tr className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<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">
<Landmark className="w-3 h-3 shrink-0" style={{ color: ACCENT }} />
<span className="truncate">{r.name}</span>
{r.calc.needsAttention && (
<TriangleAlert
className="w-3.5 h-3.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]"
aria-label="Check this agreement"
/>
)}
</div>
</td>
<td className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: r.isSettled ? undefined : ACCENT }}>
{/* A settled agreement has no LIVE instalment. Printing its 0 as "R0"
would read as a machine that costs nothing to finance; the dash means
the rate is not in play, which is what "paid off" actually means. */}
{r.isSettled ? (
<span className="text-gray-300 dark:text-text-soft italic font-normal text-[12px]">paid off</span>
) : (
ZAR2(r.installment)
)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums whitespace-nowrap text-gray-700 dark:text-gray-300">
{r.calc.monthsRemaining === null ? (
<Unknown />
) : (
<span className="font-semibold">{r.calc.monthsRemaining}</span>
)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap text-[#003D5B] dark:text-white">
{r.calc.outstanding === null ? <Unknown /> : ZAR(r.calc.outstanding)}
</td>
<td className="py-2.5 px-3 whitespace-nowrap text-gray-600 dark:text-gray-300">
{endLabel ?? <Unknown />}
</td>
<td className="py-2 px-3 text-center">
<SettledToggle settled={r.isSettled} pending={pending} saved={saved} onToggle={onToggle} />
</td>
<td className="py-2 px-6 text-right">
<button
onClick={onEdit}
aria-expanded={isEditing}
aria-label={`${isEditing ? "Close" : "Set"} agreement dates for ${r.code}`}
className="min-h-[44px] min-w-[44px] px-3 rounded-lg text-xs font-medium inline-flex items-center gap-1.5 border transition bg-[#30638E]/5 text-[#30638E] border-[#30638E]/25 hover:bg-[#30638E]/10"
>
{isEditing ? <X className="w-3.5 h-3.5" /> : <CalendarClock className="w-3.5 h-3.5" />}
{isEditing ? "Close" : "Set dates"}
</button>
</td>
</tr>
);
}
/** "not entered" — deliberately NOT R0. See lib/financing.ts. */
function Unknown() {
return <span className="text-gray-300 dark:text-text-soft italic font-normal text-[12px]">—</span>;
}
/**
* The agreement-date editor, rendered as its own full-width card above the table
* rather than inside it — see the comment at the call site for why.
*/
function DateEditor({
row, pending, onSave, onCancel,
}: {
row: FinancingRow;
pending: boolean;
onSave: (patch: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const [start, setStart] = useState(toInputValue(row.startYear, row.startMonth));
const [end, setEnd] = useState(toInputValue(row.endYear, row.endMonth));
return (
<section className="mb-4 rounded-2xl border border-[#30638E]/30 bg-[#30638E]/[0.05] p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
{row.code}
</span>
<span className="text-sm font-medium text-[#003D5B] dark:text-white truncate">{row.name}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 truncate hidden sm:inline">
· {ZAR2(row.installment)} / month
</span>
</div>
<button
onClick={onCancel}
aria-label="Close the date editor"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
<div className="flex-1">
<label htmlFor={`start-${row.id}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">
Finance start
</label>
<input
id={`start-${row.id}`}
type="month"
value={start}
onChange={(e) => setStart(e.target.value)}
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 tabular-nums"
/>
</div>
<div className="flex-1">
<label htmlFor={`end-${row.id}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">
Planned paid off
</label>
<input
id={`end-${row.id}`}
type="month"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 tabular-nums"
/>
</div>
<button
onClick={() => {
const st = fromInputValue(start);
const en = fromInputValue(end);
onSave({
startYear: st.year, startMonth: st.month,
endYear: en.year, endMonth: en.month,
});
onCancel();
}}
disabled={pending}
className="min-h-[44px] px-5 rounded-lg text-xs font-semibold text-white transition disabled:opacity-50"
style={{ background: ACCENT }}
>
Save dates
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
Leave a date blank if you don&apos;t know it — the page will then say{" "}
<span className="italic">&mdash;</span>, never R 0.
</p>
</section>
);
}
function SettledToggle({
settled, onToggle, pending, saved,
}: {
settled: boolean;
onToggle: () => void;
pending: boolean;
saved: boolean;
}) {
return (
<button
onClick={onToggle}
disabled={pending}
aria-pressed={!settled}
aria-label={settled ? "Financing settled — activate to reopen" : "Financing active — activate to mark settled"}
className={`min-h-[44px] min-w-[88px] px-3 rounded-lg text-xs font-medium transition border disabled:opacity-50 inline-flex items-center justify-center gap-1.5 ${
settled
? "bg-[#30638E]/10 text-[#30638E] border-[#30638E]/30"
: "bg-[#EDAE49]/10 text-[#EDAE49] border-[#EDAE49]/30"
}`}
>
{saved ? (
<>
<Check className="w-3.5 h-3.5" /> Saved
</>
) : settled ? (
"Settled"
) : (
"Active"
)}
</button>
);
}