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>
435 lines
No EOL
17 KiB
TypeScript
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>
|
|
);
|
|
} |