Two different causes, both from the same root — the header was raised to z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over it, which quietly pushed my overlays underneath it. The toast sat at z-60 and the Add Invoice dialog at z-50, both below the header, so a confirmation appeared to vanish behind the nav bar. Rung out against the existing ladder instead of picking a new number: header 1100, nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit at the same layer deliberately — they never open at the same time. The machine dropdown was a separate and worse bug: it was not merely behind anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto container, so the list was cut off by the table edge and the card around it — it could never escape, which is why the search appeared to do nothing. It now renders through a portal into <body>, positioned from the input's measured viewport rect, so no ancestor can clip it. The rect is measured before paint so it never flashes in the wrong place, kept in sync with scroll and resize, and the list flips upward when there is not enough room below — which is the common case on a phone with the keyboard open. 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-[1250] 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-surface 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-border">
|
|
<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-border bg-gray-50/60 dark:bg-white/[0.04] 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-surface border border-gray-100 dark:border-border 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-border">
|
|
<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-border">
|
|
{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-text-soft 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>
|
|
);
|
|
} |