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>
405 lines
No EOL
17 KiB
TypeScript
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't know it — the page will then say{" "}
|
|
<span className="italic">—</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>
|
|
);
|
|
} |