"use client"; import { useState, useRef, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X, } from "lucide-react"; import { ZAR2 } from "@/lib/utils"; // "Add Invoice" — drop a PDF and the app parses it itself. No agent, no AI: the // figures are read by coordinate from a fixed report template and checked // arithmetically before anything is written. export function AddInvoiceButton() { const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [dragging, setDragging] = useState(false); const [result, setResult] = useState(null); const inputRef = useRef(null); const router = useRouter(); const send = useCallback( async (file: File) => { setBusy(true); setResult(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) { const unassigned = (data.unassigned ?? []) as string[]; setResult({ ok: true, title: `${data.invoice_no} imported`, body: `${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 a plant item." : "."), }); } else { setResult({ ok: false, title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import", body: data.error ?? "Unknown error", }); } router.refresh(); } catch { setResult({ ok: false, title: "Could not reach the server", body: "Check the app is running." }); } finally { setBusy(false); } }, [router], ); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open]); if (!open) { return ( ); } return (

Add invoice

!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); const f = e.dataTransfer.files?.[0]; if (f) send(f); }} className={`flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-4 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" : ""}`} > {busy ? ( ) : ( )}
{busy ? "Reading the invoice…" : "Drop the invoice PDF here, or tap to browse"}
PDF only · up to 20 MB · figures are read from the invoice and checked before saving
{ const f = e.target.files?.[0]; if (f) send(f); e.target.value = ""; }} /> {result && (
{result.ok ? ( ) : ( )}
{result.title}
{result.body}
)}
); } 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")}` : ""}

  • ))}
)}
); }