mega-plant/components/InvoiceIntake.tsx
David 80ca4788a2 Give external invoices a supplier name; keep them off the sites page
The name on an invoice means two different things. On an internal invoice it is a
SITE, transcribed from the invoice's own description. On an external invoice it is
the SUPPLIER - and the PDF does not print the supplier's company name at all, so
there is nothing to transcribe and a human has to type it.

`invoices.site` stays the single field; the column header follows the page (Site on
Internal, Supplier on External) and only the external one is editable, via
components/SupplierCell.tsx and PATCH /api/invoices/:id/site. It saves on blur or
Enter, never per keystroke, and an empty value clears the name so a stray
description fragment can be removed rather than displayed as a supplier.

Sites stop taking their names from external invoices, in both places:

- syncSitesFromInvoices only creates sites from internal invoices, so a job
  description can never be registered as a yard.
- buildSiteSummaries only counts internal invoice lines towards a site, which is
  what /sites and the dashboard's per-site chart read.

This is the "Move containers" case. PHI 192's description is "Crane Truck Hire 17
September 2026 Move containers", which had registered a site literally called
"Move containers" - a job, not a yard - on /sites, on the dashboard's per-site
chart and in the plant register's site filter. The record stays in the collection
(sites are never deleted) but is no longer surfaced; the count went 13 -> 12.
External income is not lost by this - it is booked to a machine and still appears on
/income and /charts. It is simply not a site.

The intake route stops resolving an external invoice's text against the site list,
which would have invented a site out of a description, and reports needsSupplier so
the import toast says the name is outstanding.

npm run seed now captures existing invoice names before it wipes the collection and
re-applies them where data/source does not specify one - the same data-loss trap the
uploaded PDFs already had. A JSON name always wins, so internal sites are unchanged.

Verified: typecheck clean, 45 tests pass (4 new, covering the "Move containers" case
directly), production build green, the new PATCH route 401s when anonymous, and
/sites plus both invoice pages pass the mobile checklist at 375x812 and 768x1024. The
supplier edit was exercised end to end and the test value reverted.

Still open: the three external invoices (PHI 178/186/192) have no supplier name yet
and none can be derived from the data. They show "Set supplier" until the client
supplies them.

Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 12:04:42 +02:00

448 lines
No EOL
18 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
FileUp, Loader2, AlertTriangle, RotateCw, Inbox, Trash2, X, UploadCloud, FileText,
} from "lucide-react";
import { Toast } from "@/components/Toast";
import { ZAR2 } from "@/lib/utils";
// "Add Invoice" — opens a standalone dialog. The file is chosen or dropped into the
// modal and nothing is uploaded until OK is pressed, so a mis-drop can be cleared
// without leaving a stray record in the inbox.
export function AddInvoiceButton() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<null | { title: string; body: string }>(null);
// The dialog closes on success, which would otherwise leave no feedback at all — so
// a toast confirms it landed. Anything still needing attention is named in it AND
// badged on the invoice row.
const [toast, setToast] = useState<null | { message: string; detail: string; tone: "success" | "notice" }>(null);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const reset = useCallback(() => {
setFile(null);
setError(null);
setDragging(false);
}, []);
const close = useCallback(() => {
if (busy) return;
setOpen(false);
reset();
}, [busy, reset]);
// Escape closes, and the page behind the dialog must not scroll under it.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, close]);
const accept = (f: File | undefined) => {
if (!f) return;
if (f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")) {
setError({ title: "Not a PDF", body: `${f.name} — only PDF invoices are accepted.` });
setFile(null);
return;
}
if (f.size > 20 * 1024 * 1024) {
setError({
title: "Too large",
body: `${(f.size / 1048576).toFixed(1)} MB — the limit is 20 MB.`,
});
setFile(null);
return;
}
setError(null);
setFile(f);
};
const upload = async () => {
if (!file) return;
setBusy(true);
setError(null);
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch("/api/invoices/intake", { method: "POST", body: fd });
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (data.ok) {
// Success closes the dialog and confirms with a toast, so the click is never
// silent. A FAILURE stays open, because the reason is the whole point.
const unassigned = (data.unassigned ?? []) as string[];
setToast({
tone: "success",
message: `${data.invoice_no} imported`,
detail:
`${data.lines} line${data.lines > 1 ? "s" : ""} · ${ZAR2(data.total_incl)} · ` +
`${data.booked} booked` +
(unassigned.length
? ` — needs a plant item: ${unassigned.join(", ")}`
: data.fullyBooked
? " · all lines matched"
: "") +
// An external invoice has no supplier name until someone types it. Naming
// that here beats leaving the reader to find an empty column.
(data.needsSupplier ? " · add the supplier on External Invoices" : ""),
});
reset();
setOpen(false);
router.refresh();
return;
} else if (data.status === "duplicate") {
// Already in the register. Nothing was added and nothing is wrong, so this closes
// the dialog and reports it — it is not a problem invoice and does not go to the
// retry inbox. The detail names the copy you already have so it is obvious which
// one this matched.
const e = data.existing ?? {};
setToast({
tone: "notice",
message: `${data.invoice_no} is already imported`,
detail:
`${e.site ?? "—"} · ${e.lines ?? 0} line${e.lines === 1 ? "" : "s"} · ` +
`${ZAR2(e.total_incl ?? 0)} — nothing was added.`,
});
reset();
setOpen(false);
router.refresh();
return;
} else {
setError({
title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
body: data.error ?? "Unknown error",
});
router.refresh();
}
} catch {
setError({ title: "Could not reach the server", body: "Check the app is running." });
} finally {
setBusy(false);
}
};
return (
<>
<button
type="button"
onClick={() => {
setToast(null);
setOpen(true);
}}
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-xl bg-gradient-to-br from-[#00798C] to-[#003D5B] text-white text-sm font-semibold shadow-lg shadow-teal-500/20 hover:opacity-90 transition"
>
<FileUp className="w-4 h-4" />
Add Invoice
</button>
{toast && (
<Toast
message={toast.message}
detail={toast.detail}
tone={toast.tone}
onDismiss={() => setToast(null)}
/>
)}
{open && (
<div className="fixed inset-0 z-[1250] flex items-end sm:items-center justify-center p-0 sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={close}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-label="Add invoice"
className="relative w-full sm:max-w-lg bg-white dark:bg-surface rounded-t-2xl sm:rounded-2xl shadow-2xl border border-gray-100 dark:border-white/10 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-border">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Add invoice</h2>
<button
type="button"
onClick={close}
disabled={busy}
aria-label="Close"
className="grid place-items-center w-11 h-11 -mr-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5">
<div
role="button"
tabIndex={0}
aria-label="Choose or drop an invoice PDF"
onClick={() => !busy && inputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
accept(e.dataTransfer.files?.[0]);
}}
className={`flex flex-col items-center justify-center gap-2 text-center px-4 py-8 rounded-xl border-2 border-dashed cursor-pointer transition ${
dragging
? "border-[#00798C] bg-[#00798C]/10"
: "border-gray-200 dark:border-white/10 hover:border-[#00798C]/50 hover:bg-gray-50 dark:hover:bg-white/[0.02]"
} ${busy ? "opacity-60 pointer-events-none" : ""}`}
>
<UploadCloud className="w-7 h-7 text-[#00798C]" />
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
Drop the invoice PDF here
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
or tap to browse your files
</div>
</div>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
onChange={(e) => {
accept(e.target.files?.[0]);
e.target.value = "";
}}
/>
{file && (
<div className="mt-3 flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#00798C]/10">
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[#003D5B] dark:text-white truncate">
{file.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{(file.size / 1024).toFixed(0)} KB · ready to upload
</div>
</div>
{!busy && (
<button
type="button"
onClick={reset}
aria-label="Remove the selected file"
className="grid place-items-center w-11 h-11 -mr-1 rounded-lg text-gray-500 hover:bg-white/60 dark:hover:bg-white/10 transition"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)}
{error && (
<div className="mt-3 flex items-start gap-2.5 px-3 py-2.5 rounded-xl text-sm bg-[#D1495B]/10 text-[#D1495B]">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="font-semibold">{error.title}</div>
<div className="mt-0.5 break-words">{error.body}</div>
</div>
</div>
)}
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Figures are read from the invoice and checked before anything is saved. If it
does not reconcile it is kept in the problem inbox instead of imported.
</p>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-100 dark:border-border bg-gray-50/60 dark:bg-white/[0.04] rounded-b-2xl">
<button
type="button"
onClick={close}
disabled={busy}
className="inline-flex items-center justify-center min-h-[44px] px-4 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={upload}
disabled={!file || busy}
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold text-white bg-gradient-to-br from-[#00798C] to-[#003D5B] hover:opacity-90 transition disabled:opacity-40 disabled:pointer-events-none"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <FileUp className="w-4 h-4" />}
{busy ? "Importing…" : "OK — Import"}
</button>
</div>
</div>
</div>
)}
</>
);
}
type IntakeItem = {
id: string;
file_name: string;
file_size: number;
invoice_no: string;
status: "imported" | "failed";
error: string;
created: string;
};
// The problem-invoice inbox. Anything that could not be parsed or imported lands here
// with the reason, so it can be inspected and retried instead of disappearing.
export function ProblemInbox() {
const [items, setItems] = useState<IntakeItem[] | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const router = useRouter();
const load = useCallback(async () => {
try {
const res = await fetch("/api/invoices/intake?status=failed", { cache: "no-store" });
const data = await res.json().catch(() => ({ ok: false, items: [] }));
setItems(data.ok ? data.items : []);
} catch {
setItems([]);
}
}, []);
useEffect(() => {
load();
}, [load]);
const retry = async (id: string) => {
setRetrying(id);
setMsg(null);
try {
const res = await fetch(`/api/invoices/intake/${id}/retry`, { method: "POST" });
const data = await res.json().catch(() => ({ ok: false }));
setMsg(
data.ok
? `${data.invoice_no} imported on retry.`
: `Still failing: ${data.error ?? "unknown reason"}`,
);
await load();
router.refresh();
} catch {
setMsg("Could not reach the server");
} finally {
setRetrying(null);
}
};
const discard = async (id: string) => {
setRetrying(id);
try {
await fetch(`/api/invoices/intake/${id}`, { method: "DELETE" });
await load();
} catch {
setMsg("Could not reach the server");
} finally {
setRetrying(null);
}
};
return (
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm overflow-hidden mb-6">
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-gray-100 dark:border-border">
<Inbox className="w-4 h-4 text-[#D1495B] shrink-0" />
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Problem invoices</h2>
<span className="text-xs px-2 py-0.5 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-semibold">
{items?.length ?? 0}
</span>
</div>
{msg && (
<div className="px-5 py-2.5 bg-[#EDAE49]/10 text-[#8a6420] dark:text-[#EDAE49] text-sm">{msg}</div>
)}
{items === null ? (
<p className="px-5 py-6 text-sm text-gray-500 dark:text-gray-400">Checking…</p>
) : items.length === 0 ? (
<p className="px-5 py-6 text-sm text-gray-500 dark:text-gray-400">
Nothing in the inbox — every PDF offered so far imported cleanly.
</p>
) : (
<ul className="divide-y divide-gray-100 dark:border-border">
{items.map((it) => (
<li key={it.id} className="px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-semibold text-[#003D5B] dark:text-white">
{it.file_name}
</span>
{it.invoice_no && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-white/5 text-gray-600 dark:text-gray-300">
reads as {it.invoice_no}
</span>
)}
</div>
<p className="text-sm text-[#D1495B] mt-1.5 break-words">{it.error}</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
{(it.file_size / 1024).toFixed(0)} KB
{it.created ? ` · ${new Date(it.created).toLocaleString("en-ZA")}` : ""}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<a
href={`/api/invoices/intake/${it.id}/document`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${it.file_name} in a new tab`}
className="inline-flex items-center gap-1.5 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
<FileText className="w-4 h-4" />
PDF
</a>
<button
type="button"
onClick={() => retry(it.id)}
disabled={retrying === it.id}
className="inline-flex items-center gap-1.5 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
{retrying === it.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCw className="w-4 h-4" />
)}
Retry
</button>
<button
type="button"
onClick={() => discard(it.id)}
disabled={retrying === it.id}
aria-label={`Discard ${it.file_name}`}
className="inline-flex items-center justify-center w-11 h-11 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}