"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(null); const [editingId, setEditingId] = useState(null); const scroller = useRef(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) { 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 (
{liveCount} financed {attention > 0 && ( {attention} need attention )}
Sort: {([ { id: "installment", label: "Instalment" }, { id: "outstanding", label: "Owing" }, { id: "code", label: "Code" }, ] as const).map((s) => ( ))}
{/* 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 && ( save(editingRow.id, patch)} onCancel={() => setEditingId(null)} /> )}
{sorted.map((r) => { const endLabel = formatMonth(r.endYear, r.endMonth); const isEditing = editingId === r.id; return ( setEditingId(isEditing ? null : r.id)} onToggle={() => save(r.id, { isSettled: !r.isSettled })} /> ); })}
Code Vehicle Instalment per month · {VAT_NOTE} Months left Still owing Paid off by Financing Dates
Total · {liveCount} live of {rows.length} {ZAR(totalInstalment)} {withKnownOwing} of {rows.length} dated {withKnownOwing ? ZAR(knownOutstanding) : "—"}
{overflows && }
); } 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 ( {r.code}
{r.name} {r.calc.needsAttention && ( )}
{/* 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 ? ( paid off ) : ( ZAR2(r.installment) )} {r.calc.monthsRemaining === null ? ( ) : ( {r.calc.monthsRemaining} )} {r.calc.outstanding === null ? : ZAR(r.calc.outstanding)} {endLabel ?? } ); } /** "not entered" — deliberately NOT R0. See lib/financing.ts. */ function Unknown() { return —; } /** * 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) => void; onCancel: () => void; }) { const [start, setStart] = useState(toInputValue(row.startYear, row.startMonth)); const [end, setEnd] = useState(toInputValue(row.endYear, row.endMonth)); return (
{row.code} {row.name} · {ZAR2(row.installment)} / month
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" />
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" />

Leave a date blank if you don't know it — the page will then say{" "} —, never R 0.

); } function SettledToggle({ settled, onToggle, pending, saved, }: { settled: boolean; onToggle: () => void; pending: boolean; saved: boolean; }) { return ( ); }