mega-plant/components/PurchaseIntake.tsx

881 lines
42 KiB
TypeScript
Raw Permalink 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, useRef, useTransition, useMemo } from "react";
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
import { useRouter } from "next/navigation";
import {
Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert,
Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft, Pencil,
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
} from "lucide-react";
import { ZAR, ZAR2 } from "@/lib/utils";
import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status";
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
const ACCENT = "#30638E";
export type PurchaseLineView = {
id: string;
line_no: number;
material_code: string;
cost_code: string;
description: string;
qty: number | null;
rate: number | null;
amount_excl: number;
vat: number | null;
total_incl: number | null;
assetCode: string;
assetName: string;
};
export type PurchaseView = {
id: string;
po_no: string;
supplier: string;
supplier_code: string;
po_date: string;
required_by: string;
req_no: string;
currency: string;
subtotal: number;
vat: number;
total: number;
source_file: string;
invoices: PurchaseInvoiceView[];
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
lines: PurchaseLineView[];
};
export type PurchaseInvoiceView = {
id: string;
invoice_no: string;
invoice_date: string;
amount_incl: number | null;
note: string;
hasPdf: boolean;
};
export function PurchaseIntake({ purchases: allPurchases }: { purchases: PurchaseView[] }) {
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
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [pending, startTransition] = useTransition();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [invoiceModal, setInvoiceModal] = useState(false);
const [range, setRange] = useState<"all" | "week" | "month">("all");
// 0 = the current week/month, -1 = the one before, …
const [offset, setOffset] = useState(0);
const [groupBySupplier, setGroupBySupplier] = useState(true);
const [dragging, setDragging] = useState(false);
// Deleting and correcting are hidden until this is switched on, and it resets on reload.
const [editMode, setEditMode] = useState(false);
// Drop a PO anywhere on the page. Not while the invoice window is open: that has its own
// drop area, and an invoice PDF must never be read as a purchase order.
const dropProps = {
onDragOver: (e: React.DragEvent) => {
if (invoiceModal || busy || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragging(true);
},
onDragLeave: (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);
},
onDrop: (e: React.DragEvent) => {
if (invoiceModal || busy) return;
e.preventDefault();
setDragging(false);
upload(e.dataTransfer.files);
},
};
const win = useMemo(() => (range === "all" ? null : rangeWindow(range, offset)), [range, offset]);
// A PO with no readable date cannot be placed in a week or month, so a range hides it —
// and says so, rather than letting it quietly vanish.
const purchases = useMemo(
() => (win ? allPurchases.filter((p) => /^\d{4}-\d{2}-\d{2}/.test(p.po_date) && p.po_date.slice(0, 10) >= win.from && p.po_date.slice(0, 10) <= win.to) : allPurchases),
[allPurchases, win],
);
const undated = win ? allPurchases.filter((p) => !/^\d{4}-\d{2}-\d{2}/.test(p.po_date)).length : 0;
const groups = useMemo(() => {
if (!groupBySupplier) return [{ key: "", name: "", items: purchases }];
const m = new Map<string, PurchaseView[]>();
for (const p of purchases) m.set(p.supplier || "No supplier", [...(m.get(p.supplier || "No supplier") ?? []), p]);
return [...m.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, items]) => ({ key: name, name, items }));
}, [purchases, groupBySupplier]);
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
async function upload(files: FileList | null) {
if (!files || !files.length) return;
setBusy(true);
setError(null);
setNotice(null);
try {
for (const f of Array.from(files)) {
const fd = new FormData();
fd.append("file", f);
const res = await fetch("/api/purchases/intake", { method: "POST", body: fd });
const body = await res.json();
if (!body.ok) {
setError(`${f.name}: ${body.error}`);
break;
}
// A line that found no machine is money that exists but is not yet on one.
// Named, not buried — that is the whole point of reading the cost code.
setNotice(
body.unallocated?.length
? `${body.purchase.po_no}: ${body.lines} line(s) imported, but no machine matched ${body.unallocated.join(", ")} — left unallocated.`
: `${body.purchase.po_no}: ${body.lines} line(s) imported and allocated.`,
);
}
startTransition(() => router.refresh());
} catch {
setError("Upload failed — check your connection");
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
}
}
const totalSpend = purchases.reduce((s, p) => s + p.total, 0);
const unallocatedLines = purchases.reduce(
(s, p) => s + p.lines.filter((l) => !l.assetCode).length,
0,
);
return (
<div {...dropProps} className={`relative rounded-2xl transition ${dragging ? "ring-2 ring-dashed ring-[#30638E] ring-offset-4 bg-[#30638E]/5" : ""}`}>
{dragging && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<div className="rounded-xl bg-[#30638E] text-white text-sm font-medium px-5 py-3 shadow-lg inline-flex items-center gap-2">
<Upload className="w-4 h-4" /> Drop purchase order PDFs to upload
</div>
</div>
)}
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
<div className="flex flex-col sm:flex-row 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 font-medium" style={{ background: `${ACCENT}1a`, color: ACCENT }}>
{purchases.length} purchase order{purchases.length !== 1 && "s"}
</span>
<span className="px-2.5 py-1 rounded-md bg-gray-100 text-gray-500 dark:bg-white/5 dark:text-gray-400 font-medium">
{ZAR(totalSpend)} total
</span>
{unallocatedLines > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
{unallocatedLines} line{unallocatedLines !== 1 && "s"} with no machine
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setInvoiceModal(true)}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white"
style={{ background: BRAND_ORANGE }}
>
<Receipt className="w-4 h-4" /> Upload invoice
</button>
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
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
multiple
// The input stays sr-only and the LABEL is the 44px control. It is still
// focusable, so `peer-focus-visible` puts the focus ring on the label —
// otherwise a keyboard user tabs onto an invisible 1px target.
className="sr-only peer"
id="po-upload"
onChange={(e) => upload(e.target.files)}
/>
<label
htmlFor="po-upload"
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white cursor-pointer peer-focus-visible:ring-2 peer-focus-visible:ring-[#30638E] peer-focus-visible:ring-offset-2"
style={{ background: ACCENT }}
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{busy ? "Reading…" : "Upload purchase orders"}
</label>
</div>
{invoiceModal && <InvoiceUploadModal purchases={allPurchases} onClose={() => setInvoiceModal(false)} />}
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
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#D1495B]/30 bg-[#D1495B]/[0.06] p-4">
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]" />
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-semibold text-[#003D5B] dark:text-white">Could not import</span>
<div className="mt-1">{error}</div>
</div>
<button onClick={() => setError(null)} aria-label="Dismiss"
className="ml-auto min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-white dark:hover:bg-white/5">
<X className="w-4 h-4" />
</button>
</div>
)}
{notice && (
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#00798C]/30 bg-[#00798C]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
<Check className="w-4 h-4 mt-0.5 shrink-0 text-[#00798C]" />
<span>{notice}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3 mb-4">
<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 self-start">
{([["all", "All"], ["week", "Week"], ["month", "Month"]] as const).map(([id, label]) => (
<button key={id} onClick={() => { setRange(id); setOffset(0); }} aria-pressed={range === id}
className={`min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
range === id ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}>
{label}
</button>
))}
</div>
{win && (
<div className="flex items-center gap-1">
<button onClick={() => setOffset(offset - 1)} aria-label="Previous"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-xs font-medium text-[#003D5B] dark:text-white min-w-[150px] text-center">{win.label}</span>
<button onClick={() => setOffset(offset + 1)} aria-label="Next" disabled={offset >= 0}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 disabled:opacity-30"><ChevronRight className="w-4 h-4" /></button>
</div>
)}
<button
onClick={() => setEditMode(!editMode)}
aria-pressed={editMode}
className={`min-h-[44px] px-3 rounded-lg text-xs font-medium inline-flex items-center gap-1.5 border transition sm:ml-auto ${
editMode ? "bg-[#EDAE49] border-[#EDAE49] text-white" : "border-gray-200 dark:border-border text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5"
}`}
>
<Pencil className="w-4 h-4" /> {editMode ? "Editing — delete enabled" : "Edit mode"}
</button>
<label className="inline-flex items-center gap-2 min-h-[44px] text-xs text-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" checked={groupBySupplier} onChange={(e) => setGroupBySupplier(e.target.checked)} className="w-4 h-4" />
Group by supplier
</label>
</div>
{undated > 0 && (
<div className="mb-3 text-xs text-[#8a6420] dark:text-[#EDAE49]">
{undated} purchase order{undated !== 1 && "s"} with no readable date {undated !== 1 ? "are" : "is"} hidden while a range is chosen.
</div>
)}
{allPurchases.length > 0 && purchases.length === 0 ? (
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-10 shadow-sm text-center text-sm text-gray-500 dark:text-gray-400">
No purchase orders dated in this {range}.
</section>
) : purchases.length === 0 ? (
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
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 shadow-sm text-center">
<ShoppingCart className="w-8 h-8 mx-auto mb-3 text-gray-300" />
<div className="text-sm text-gray-500 dark:text-gray-400">No purchase orders yet.</div>
<div className="text-xs text-gray-400 dark:text-text-soft mt-1">
Upload a PO PDF above, or drag it onto this page. Each line is allocated to a machine by its cost code.
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
</div>
</section>
) : (
<div className="space-y-5">
{groups.map((g) => (
<div key={g.key || "all"}>
{groupBySupplier && (
<div className="flex items-baseline justify-between gap-3 mb-2 px-1">
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white truncate">{g.name}</h3>
<span className="text-xs text-gray-500 dark:text-gray-400 shrink-0 tabular-nums">
{g.items.length} PO{g.items.length !== 1 && "s"} · {ZAR2(g.items.reduce((t, x) => t + x.total, 0))}
</span>
</div>
)}
<div className="space-y-2">
{g.items.map((p) => {
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
const isOpen = open === p.id;
const unalloc = p.lines.filter((l) => !l.assetCode).length;
const inv = invoiceStatus(p);
const sty = STATUS_STYLE[inv.status];
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
return (
<div key={p.id} className="relative bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm">
<span
className="absolute left-0 inset-y-0 w-1.5"
style={{ background: sty.color }}
title={sty.label}
role="img"
aria-label={sty.label}
/>
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
<button
onClick={() => setOpen(isOpen ? null : p.id)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 sm:gap-4 p-4 text-left min-h-[44px]"
>
<FileText className="w-4 h-4 shrink-0" style={{ color: ACCENT }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
{p.po_no}
</span>
<span className="text-sm font-medium text-[#003D5B] dark:text-white truncate">{p.supplier}</span>
{unalloc > 0 && (
<TriangleAlert className="w-3.5 h-3.5 text-[#EDAE49]" aria-label="Some lines have no machine" />
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{p.po_date || "no date"} · {p.lines.length} line{p.lines.length !== 1 && "s"} ·{" "}
{p.lines.filter((l) => l.assetCode).length} allocated ·{" "}
<span style={{ color: sty.color }} className="font-medium">
{inv.status === "over" ? `invoiced ${ZAR2(inv.invoiced - p.total)} OVER the PO — check the amount` : inv.status === "complete" ? "fully invoiced — can be closed" : inv.status === "partial" ? `${ZAR2(inv.invoiced)} of ${ZAR2(p.total)} invoiced` : "no invoice yet"}
</span>
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
</div>
</div>
<div className="text-right shrink-0">
<div className="text-[12px] text-gray-400 uppercase tracking-wider">Total</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR(p.total)}
</div>
</div>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-400 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />}
</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]">
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-xs mb-4">
<Meta label="Supplier" value={p.supplier} />
<Meta label="Supplier code" value={p.supplier_code || "—"} />
<Meta label="PO date" value={p.po_date || "—"} />
<Meta label="Required by" value={p.required_by || "—"} />
<Meta label="Requisition" value={p.req_no || "—"} />
<Meta label="Currency" value={p.currency || "—"} />
<Meta label="Sub-total" value={ZAR2(p.subtotal)} />
<Meta label="VAT" value={ZAR2(p.vat)} />
</dl>
<SupplierInvoices purchase={p} editMode={editMode} />
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
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-surface-muted">
<th className="text-left py-2 px-3 font-medium">#</th>
<th className="text-left py-2 px-3 font-medium">Description</th>
<th className="text-left py-2 px-3 font-medium">Cost code</th>
<th className="text-left py-2 px-3 font-medium">Machine</th>
<th className="text-right py-2 px-3 font-medium">Amount</th>
<th className="text-right py-2 px-3 font-medium">Incl VAT</th>
</tr>
</thead>
<tbody>
{p.lines.map((l) => (
<tr key={l.id} className="border-t border-gray-100 dark:border-border">
<td className="py-2 px-3 text-gray-400">{l.line_no}</td>
<td className="py-2 px-3 text-[#003D5B] dark:text-gray-300">
{l.description || <span className="italic text-gray-400">no description</span>}
</td>
<td className="py-2 px-3 font-mono text-gray-500 dark:text-gray-400">{l.cost_code || "—"}</td>
<td className="py-2 px-3">
{l.assetCode ? (
<span className="inline-flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<span className="font-mono text-[12px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C]">
{l.assetCode}
</span>
<span className="truncate">{l.assetName}</span>
</span>
) : (
<span className="text-[12px] text-[#EDAE49] italic">no matching machine</span>
)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-gray-600 dark:text-gray-400">
{ZAR2(l.amount_excl)}
</td>
<td className="py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
{l.total_incl == null ? "—" : ZAR2(l.total_incl)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
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
</div>
)}
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-gray-400 uppercase tracking-wider text-[12px]">{label}</dt>
<dd className="text-gray-700 dark:text-gray-300 mt-0.5 break-words">{value}</dd>
</div>
);
}
const inputCls =
"min-h-[44px] w-full rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm text-[#003D5B] dark:text-white";
/**
* Supplier invoices received against one PO. A PO can have several. Typed in by a person
* with the PDF attached; the "invoiced" figure sums only the amounts actually entered, and
* an invoice with no amount is listed but never counted as R0.
*/
function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; editMode: boolean }) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [amt, setAmt] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editAmt, setEditAmt] = useState("");
const [editReason, setEditReason] = useState("");
const invoiced = p.invoices.reduce((s, i) => s + (i.amount_incl ?? 0), 0);
const unpriced = p.invoices.filter((i) => i.amount_incl == null).length;
const outstanding = p.total - invoiced;
async function add(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${p.id}/invoices`, { method: "POST", body: new FormData(form) });
const body = await res.json();
if (!body.ok) {
setError(body.error);
return;
}
if (body.over > 0) setError(`Saved — but this takes the invoices ${ZAR2(body.over)} OVER the PO total. Check the amount.`);
else if (body.warning) setError(body.warning);
form.reset();
setAmt("");
setAdding(false);
router.refresh();
} catch {
setError("Could not save — check your connection");
} finally {
setBusy(false);
}
}
function startEdit(i: PurchaseInvoiceView) {
setEditId(i.id);
setEditAmt(i.amount_incl == null ? "" : String(i.amount_incl));
setEditReason("");
setError(null);
}
async function saveEdit(i: PurchaseInvoiceView) {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount_incl: editAmt, reason: editReason }),
});
const body = await res.json();
if (!body.ok) { setError(body.error); return; }
setEditId(null);
router.refresh();
} catch {
setError("Could not save — check your connection");
} finally {
setBusy(false);
}
}
async function remove(i: PurchaseInvoiceView) {
if (!confirm(`Remove invoice ${i.invoice_no} from ${p.po_no}?`)) return;
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, { method: "DELETE" });
const body = await res.json().catch(() => ({ ok: false, error: "Could not remove" }));
if (!body.ok) setError(body.error);
else router.refresh();
}
return (
<div className="mb-4 rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface p-3">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="text-xs font-semibold text-[#003D5B] dark:text-white">
Supplier invoices
{p.invoices.length > 0 && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{ZAR2(invoiced)} invoiced · {ZAR2(Math.abs(outstanding))} of PO {outstanding < 0 ? "over" : "not yet invoiced"}
{unpriced > 0 && ` · ${unpriced} with no amount`}
</span>
)}
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="min-h-[44px] px-3 rounded-lg text-xs font-medium text-white inline-flex items-center gap-1.5"
style={{ background: ACCENT }}
>
<Plus className="w-4 h-4" /> Add invoice
</button>
)}
</div>
{p.invoices.length === 0 && !adding && (
<div className="text-xs text-gray-400">No invoices attached to this PO yet.</div>
)}
{p.invoices.length > 0 && (
<ul className="divide-y divide-gray-100 dark:divide-border">
{p.invoices.map((i) => editId === i.id ? (
<li key={i.id} className="py-2 space-y-2">
<div className="text-xs font-semibold text-[#003D5B] dark:text-white">Correct invoice {i.invoice_no}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Amount incl VAT (PO is {ZAR2(p.total)})
<input value={editAmt} onChange={(e) => setEditAmt(e.target.value)} inputMode="decimal" className={inputCls} autoFocus />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Reason for the change (kept in the log)
<input value={editReason} onChange={(e) => setEditReason(e.target.value)} placeholder="e.g. decimal point missed when captured" className={inputCls} />
</label>
</div>
<div className="flex gap-2">
<button onClick={() => saveEdit(i)} disabled={busy || editReason.trim().length < 3}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50" style={{ background: ACCENT }}>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save correction
</button>
<button onClick={() => setEditId(null)} className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5">Cancel</button>
</div>
</li>
) : (
<li key={i.id} className="flex items-center gap-3 py-1">
<div className="flex-1 min-w-0 text-xs">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{i.invoice_no}</span>
<span className="text-gray-500 dark:text-gray-400">
{" "}· {i.invoice_date || "no date"}
{i.note && ` · ${i.note}`}
</span>
</div>
<div
onDoubleClick={() => editMode && startEdit(i)}
title={editMode ? "Double-click to correct this amount" : "Switch on Edit mode to correct this amount"}
className="text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 cursor-text select-none"
>
{i.amount_incl == null ? <span className="font-normal italic text-gray-400">no amount</span> : ZAR2(i.amount_incl)}
</div>
{editMode && (
<button
onClick={() => startEdit(i)}
aria-label={`Correct the amount of ${i.invoice_no}`}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
<Pencil className="w-4 h-4" />
</button>
)}
{i.hasPdf ? (
<a
href={`/api/purchases/${p.id}/invoices/${i.id}/document`}
target="_blank"
rel="noreferrer"
aria-label={`Open PDF for ${i.invoice_no}`}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
<FileText className="w-4 h-4" />
</a>
) : (
<span className="min-w-[44px] text-center text-[12px] text-gray-400">no PDF</span>
)}
{editMode && (
<button
onClick={() => remove(i)}
aria-label={`Remove invoice ${i.invoice_no}`}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#D1495B] hover:bg-gray-50 dark:hover:bg-white/5"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</li>
))}
</ul>
)}
{error && <div className="mt-2 text-xs text-[#D1495B] dark:text-[#D94F60]">{error}</div>}
{adding && (
<form onSubmit={add} className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice number *
<input name="invoice_no" required className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice date
<input name="invoice_date" type="date" className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Amount incl VAT
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
<OverWarning po={p} typed={amt} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Note
<input name="note" placeholder="e.g. part delivery" className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Invoice PDF
<input name="file" type="file" accept="application/pdf,.pdf" className={`${inputCls} py-2`} />
</label>
<div className="sm:col-span-2 flex gap-2">
<button
type="submit"
disabled={busy}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-60"
style={{ background: ACCENT }}
>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save invoice
</button>
<button
type="button"
onClick={() => { setAdding(false); setError(null); }}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5"
>
Cancel
</button>
</div>
</form>
)}
</div>
);
}
function invoiceStatus(p: PurchaseView) {
return invoiceStatusOf(p.total, p.invoices.map((i) => i.amount_incl));
}
const BRAND_ORANGE = "#EDAE49";
/**
* Global "Upload invoice": find the PO by number, supplier, amount or line description,
* then give the invoice number, date, amount and PDF (drag and drop or browse).
*/
function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[]; onClose: () => void }) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [poId, setPoId] = useState("");
const [file, setFile] = useState<File | null>(null);
const [amt, setAmt] = useState("");
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const q = query.trim().toLowerCase().replace(/[\s,]/g, "");
const matches = purchases
.filter((p) => {
if (!q) return true;
const hay = [
p.po_no, p.supplier, p.supplier_code,
p.total.toFixed(2), String(Math.round(p.total)),
...p.lines.map((l) => `${l.description} ${l.cost_code}`),
].join(" ").toLowerCase().replace(/[\s,]/g, "");
return hay.includes(q);
})
.slice(0, 8);
const po = purchases.find((p) => p.id === poId) || null;
const st = po ? invoiceStatus(po) : null;
function pick(f: File | undefined) {
if (!f) return;
if (!f.name.toLowerCase().endsWith(".pdf")) {
setError("Invoices must be PDF files");
return;
}
setError(null);
setFile(f);
}
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!po) {
setError("Choose the purchase order first");
return;
}
const fd = new FormData(e.currentTarget);
if (file) fd.set("file", file);
else fd.delete("file");
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${po.id}/invoices`, { method: "POST", body: fd });
const body = await res.json();
if (!body.ok) {
setError(body.error);
return;
}
router.refresh();
onClose();
} catch {
setError("Could not save — check your connection");
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center p-4 overflow-y-auto" onClick={onClose}>
<form
onSubmit={submit}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Upload invoice"
className="w-full max-w-lg bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3"
>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Upload invoice</h2>
<button type="button" onClick={onClose} aria-label="Close"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-50 dark:hover:bg-white/5">
<X className="w-4 h-4" />
</button>
</div>
<div>
<label className="text-[12px] text-gray-500 dark:text-gray-400" htmlFor="inv-search">
Find the purchase order — number, supplier, amount or description
</label>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input id="inv-search" value={query} onChange={(e) => { setQuery(e.target.value); setPoId(""); }}
placeholder="e.g. MEGPPO49676, tyres, 12500" autoComplete="off"
className={`${inputCls} pl-9`} />
</div>
{!po && (
<ul className="mt-1 max-h-56 overflow-y-auto rounded-lg border border-gray-100 dark:border-border divide-y divide-gray-100 dark:divide-border">
{matches.length === 0 && <li className="p-3 text-xs text-gray-400">No purchase order matches.</li>}
{matches.map((p) => (
<li key={p.id}>
<button type="button" onClick={() => setPoId(p.id)}
className="w-full min-h-[44px] flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-white/5">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[invoiceStatus(p).status].color }} />
<span className="flex-1 min-w-0 text-xs">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{p.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {p.supplier}</span>
<span className="block truncate text-gray-400">{p.lines[0]?.description}</span>
</span>
<span className="text-xs tabular-nums font-semibold shrink-0">{ZAR2(p.total)}</span>
</button>
</li>
))}
</ul>
)}
{po && st && (
<div className="mt-1 flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-white/5 p-2 text-xs">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[st.status].color }} />
<span className="flex-1 min-w-0">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{po.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {po.supplier}</span>
</span>
<button type="button" onClick={() => setPoId("")}
className="min-h-[44px] px-2 text-[#30638E] font-medium">Change</button>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice number *
<input name="invoice_no" required className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice date
<input name="invoice_date" type="date" className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Amount incl VAT
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
{po && <OverWarning po={po} typed={amt} />}
{po && st && (
<span className="block mt-1 text-xs text-gray-500 dark:text-gray-400">
PO value <span className="font-semibold text-[#003D5B] dark:text-white tabular-nums">{ZAR2(po.total)}</span>
{" · "}already invoiced {ZAR2(st.invoiced)}
{" · "}left <span className="font-semibold tabular-nums">{ZAR2(Math.max(po.total - st.invoiced, 0))}</span>
</span>
)}
</label>
</div>
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files[0]); }}
className={`rounded-xl border-2 border-dashed p-4 text-center text-xs transition-colors ${
drag ? "border-[#EDAE49] bg-[#EDAE49]/10" : "border-gray-200 dark:border-border"
}`}
>
<input ref={fileRef} type="file" accept="application/pdf,.pdf" className="sr-only"
onChange={(e) => pick(e.target.files?.[0])} />
{file ? (
<div className="flex items-center justify-center gap-2 text-[#003D5B] dark:text-white">
<FileText className="w-4 h-4" /> {file.name}
<button type="button" onClick={() => setFile(null)} aria-label="Remove file"
className="min-h-[44px] min-w-[44px] grid place-items-center"><X className="w-4 h-4" /></button>
</div>
) : (
<>
<Upload className="w-5 h-5 mx-auto mb-1 text-gray-400" />
<div className="text-gray-500 dark:text-gray-400">Drag the invoice PDF here, or</div>
<button type="button" onClick={() => fileRef.current?.click()}
className="mt-1 min-h-[44px] px-4 rounded-lg text-xs font-medium text-white"
style={{ background: BRAND_ORANGE }}>
Choose file
</button>
</>
)}
</div>
{error && <div className="text-xs text-[#D1495B] dark:text-[#D94F60]">{error}</div>}
<div className="flex gap-2 justify-end">
<button type="button" onClick={onClose}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5">
Cancel
</button>
<button type="submit" disabled={busy || !po}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50"
style={{ background: BRAND_ORANGE }}>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save invoice
</button>
</div>
</form>
</div>
);
}
const isoDay = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const fmtDay = (d: Date) => d.toLocaleDateString("en-ZA", { day: "numeric", month: "short" });
/** The week (Monday–Sunday) or calendar month `offset` periods from the current one. */
function rangeWindow(range: "week" | "month", offset: number): { from: string; to: string; label: string } {
const now = new Date();
if (range === "month") {
const first = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const last = new Date(first.getFullYear(), first.getMonth() + 1, 0);
return { from: isoDay(first), to: isoDay(last), label: first.toLocaleDateString("en-ZA", { month: "long", year: "numeric" }) };
}
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - ((now.getDay() + 6) % 7) + offset * 7);
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6);
return { from: isoDay(monday), to: isoDay(sunday), label: `${fmtDay(monday)} – ${fmtDay(sunday)}` };
}
/** Live check while typing an invoice amount: anything that takes the PO over its total is flagged before it is saved. */
function OverWarning({ po, typed }: { po: PurchaseView; typed: string }) {
const n = Number(typed.trim().replace(/\s/g, "").replace(",", "."));
if (!typed.trim() || !Number.isFinite(n)) return null;
const over = po.invoices.reduce((t, i) => t + (i.amount_incl ?? 0), 0) + n - po.total;
if (over <= 1) return null;
return (
<span className="mt-1 flex items-start gap-1.5 text-xs font-medium text-[#8B5CF6]">
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0" />
This is {ZAR2(over)} MORE than the PO ({ZAR2(po.total)}). Check for a typo before saving.
</span>
);
}