diff --git a/app/income/invoices/page.tsx b/app/income/invoices/page.tsx index 41d96f8..79917f1 100644 --- a/app/income/invoices/page.tsx +++ b/app/income/invoices/page.tsx @@ -1,7 +1,4 @@ -import { - getAssets, getInvoices, getInvoiceLines, joinBookedLines, - findDuplicateMachineCodes, -} from "@/lib/data"; +import { getAssets, getInvoices, getInvoiceLines, joinBookedLines } from "@/lib/data"; import { FileText } from "lucide-react"; import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList"; import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake"; @@ -25,7 +22,6 @@ export default async function InvoicesPage() { } const unbookedCount = joined.filter((l) => !l.isBooked).length; - const multiSite = findDuplicateMachineCodes(invoices, joined); return (
@@ -49,7 +45,7 @@ export default async function InvoicesPage() { {invoices.length === 0 ? ( ) : ( - + )} {unbookedCount > 0 && ( diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx index bec922a..e7d629d 100644 --- a/components/InvoiceIntake.tsx +++ b/components/InvoiceIntake.tsx @@ -3,70 +3,110 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { - FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X, + FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X, UploadCloud, FileText, } 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. +// "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 [busy, setBusy] = useState(false); + const [file, setFile] = useState(null); const [dragging, setDragging] = useState(false); + const [busy, setBusy] = 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], - ); + const reset = useCallback(() => { + setFile(null); + setResult(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) => e.key === "Escape" && setOpen(false); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open]); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [open, close]); - if (!open) { - return ( + const accept = (f: File | undefined) => { + if (!f) return; + if (f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")) { + setResult({ ok: false, title: "Not a PDF", body: `${f.name} — only PDF invoices are accepted.` }); + setFile(null); + return; + } + if (f.size > 20 * 1024 * 1024) { + setResult({ + ok: false, + title: "Too large", + body: `${(f.size / 1048576).toFixed(1)} MB — the limit is 20 MB.`, + }); + setFile(null); + return; + } + setResult(null); + setFile(f); + }; + + const upload = async () => { + if (!file) return; + 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." + : "."), + }); + router.refresh(); + } 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); + } + }; + + return ( + <> - ); - } - return ( -
-
-

Add invoice

- -
+ {open && ( +
+ {/* Backdrop */} +