"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(null); const [dragging, setDragging] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(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); const inputRef = useRef(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 ( <> {toast && ( setToast(null)} /> )} {open && (
{/* Backdrop */} )} ); } 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(null); const [retrying, setRetrying] = useState(null); const [msg, setMsg] = useState(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 (

Problem invoices

{items?.length ?? 0}
{msg && (
{msg}
)} {items === null ? (

Checking…

) : items.length === 0 ? (

Nothing in the inbox — every PDF offered so far imported cleanly.

) : (
    {items.map((it) => (
  • {it.file_name} {it.invoice_no && ( reads as {it.invoice_no} )}

    {it.error}

    {(it.file_size / 1024).toFixed(0)} KB {it.created ? ` · ${new Date(it.created).toLocaleString("en-ZA")}` : ""}

  • ))}
)}
); }