mega-plant/components/InvoiceIntake.tsx
David 58c972c4c9 Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.

Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.

Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:59:08 +02:00

435 lines
No EOL
17 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"
: ""),
});
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-50 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-[#131a21] 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-white/5">
<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-white/5 bg-gray-50/60 dark:bg-white/[0.02] 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-[#131a21] border border-gray-100 dark:border-white/5 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-white/5">
<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-white/5">
{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-gray-500 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">
<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>
);
}