mega-plant/components/PurchaseIntake.tsx
David f85b9378a2 Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.

Insurance (R503 330/yr, 20 vehicles)
  A premium can move every month or three, so it is stored as a RATE PERIOD in
  insurance_rates and each month's cost is resolved against the rate in force
  that month. cost_lines insurance rows are derived from those periods. An absent
  rate is null, never 0 — nobody has entered the date, which is not the same as
  being insured for free.

Financing (20 agreements, R428 585/mo)
  Instalment from the sheet; start date, paid-off date and the settled toggle are
  human-entered and preserved across re-seeds. Months-left and outstanding are
  null until a date is entered, never R0 on a live debt. The two paid-off
  agreements (WB02, DT01) show as settled and are excluded from the run-rate.

Purchases + Oil Stock
  Uploads PO PDFs the way /income/invoices does. The parser reads columns by
  x-position, because plain text extraction reorders them, and refuses any PO
  whose lines do not sum to the sub-total. A cost code either names a machine
  ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
  guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
  stock is the sum of movements so the first usage is a subtraction from history.

Operators (19) and Mechanics (2)
  Both are people with an employee number, separately reconcilable by the seed so
  someone added in the app survives. Mechanics get a badge QR; a mechanic with no
  employee number cannot be printed, because the code is what the QR encodes.

Cross-cutting
  - The seed no longer deletes operators, mechanics or oil items: people are added
    in the app, and those rows exist in no file.
  - Re-pointing allocations that PocketBase clears because `assets` is rebuilt
    with new ids each seed (operators, purchase lines).
  - One QR encoder for screen and PDF, so a printed code cannot drift from the one
    on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
  - Every figure is null-safe: unknown is never rendered as zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 01:06:27 +02:00

271 lines
No EOL
12 KiB
TypeScript

"use client";
import { useState, useRef, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert,
Check, ShoppingCart, X,
} from "lucide-react";
import { ZAR, ZAR2 } from "@/lib/utils";
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;
lines: PurchaseLineView[];
};
export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
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);
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>
<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>
<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>
</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>
)}
{purchases.length === 0 ? (
<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. Each line is allocated to a machine by its cost code.
</div>
</section>
) : (
<div className="space-y-2">
{purchases.map((p) => {
const isOpen = open === p.id;
const unalloc = p.lines.filter((l) => !l.assetCode).length;
return (
<div key={p.id} className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm">
<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
</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>
<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>
);
}
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>
);
}