The upload panel expanded inline above the table, which pushed the list down the page every time it opened. It is now a standalone modal: backdrop, Escape to close, body scroll locked, sheet on small screens and a centred dialog from sm up. The file is chosen or dropped into the modal and nothing is sent until OK — a mis-drop can be cleared without leaving a stray record in the inbox. Cancel, a busy state and inline size/type errors round it out. The status column carried two extra lines on every single row — a multi-site note and a "booked R X · R Y VAT" figure — which is repetition: the figures are already in the Revenue/VAT/Total columns, and the note restated something the client ruled on. Both are gone, leaving just the booked/unbooked badge, which is the only thing that needs acting on. The per-row booking figures stay available in the expanded detail. The plumbing those two lines used — the multi-site cross-invoice lookup and the per-row booked/VAT totals — is now unused and removed with them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
406 lines
No EOL
16 KiB
TypeScript
406 lines
No EOL
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X, UploadCloud, FileText,
|
|
} from "lucide-react";
|
|
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 [result, setResult] = useState<null | { ok: boolean; title: string; body: string }>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const router = useRouter();
|
|
|
|
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) => {
|
|
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")) {
|
|
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 (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => 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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{result && (
|
|
<div
|
|
className={`mt-3 flex items-start gap-2.5 px-3 py-2.5 rounded-xl text-sm ${
|
|
result.ok ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
|
|
}`}
|
|
>
|
|
{result.ok ? (
|
|
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
|
) : (
|
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="font-semibold">{result.title}</div>
|
|
<div className="mt-0.5 break-words">{result.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>
|
|
);
|
|
} |