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.
448 lines
No EOL
18 KiB
TypeScript
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>
|
|
);
|
|
} |