Invoices: Add Invoice becomes a real dialog; quieten the invoice table

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>
This commit is contained in:
David 2026-10-02 14:35:27 +02:00
parent 5caa923375
commit 9e6235af92
3 changed files with 244 additions and 182 deletions

View file

@ -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 (
<div className="p-4 sm:p-8 max-w-[1400px]">
@ -49,7 +45,7 @@ export default async function InvoicesPage() {
{invoices.length === 0 ? (
<EmptyInvoices />
) : (
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} multiSite={multiSite} />
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} />
)}
{unbookedCount > 0 && (

View file

@ -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<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 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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
@ -75,96 +115,154 @@ export function AddInvoiceButton() {
<FileUp className="w-4 h-4" />
Add Invoice
</button>
);
}
return (
<div className="mb-5 bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Add invoice</h2>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="grid place-items-center w-11 h-11 -mr-2 -mt-1 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
{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="button"
tabIndex={0}
aria-label="Upload 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);
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 ? (
<Loader2 className="w-5 h-5 animate-spin text-[#00798C] shrink-0" />
) : (
<FileUp className="w-5 h-5 text-[#00798C] shrink-0" />
)}
<div className="min-w-0">
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
{busy ? "Reading the invoice…" : "Drop the invoice PDF here, or tap to browse"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
PDF only · up to 20 MB · figures are read from the invoice and checked before saving
</div>
</div>
</div>
<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>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) send(f);
e.target.value = "";
}}
/>
<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>
{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>
<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>
)}
</div>
</>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText, Repeat } from "lucide-react";
import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText } from "lucide-react";
import type { Asset, BookedLine, Invoice } from "@/lib/data";
import { BOOK_INCLUSIVE_VAT, BOOKED_LONG_LABEL } from "@/lib/invoice-booking";
import { ZAR, ZAR2 } from "@/lib/utils";
@ -17,14 +17,10 @@ export function InvoiceList({
invoices,
linesByInvoice,
assets,
multiSite,
}: {
invoices: Invoice[];
linesByInvoice: Record<string, BookedLine[]>;
assets: Asset[];
// line id -> other invoice numbers billing the same machine over the same period.
// Informational only: a machine hired to two sites in a month is normal, not an error.
multiSite: Map<string, string[]>;
}) {
const [open, setOpen] = useState<string | null>(null);
@ -86,7 +82,6 @@ export function InvoiceList({
const lines = linesByInvoice[inv.id] ?? [];
const booked = lines.filter((l) => l.isBooked);
const unbooked = lines.filter((l) => !l.isBooked);
const shared = lines.filter((l) => multiSite.get(l.id)?.length);
// Only an unbooked line is a problem. A machine used at two sites in a
// month is expected, so it must not tint the row or badge it as an error.
const hasProblem = unbooked.length > 0;
@ -95,7 +90,6 @@ export function InvoiceList({
ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(booked, "amount_excl");
const bookedVat = sum(booked, "vat");
const bookedTotal = bookedAmount(bookedExVat, bookedVat);
return (
<InvoiceRow
@ -103,10 +97,6 @@ export function InvoiceList({
invoice={inv}
lineCount={lines.length}
unbooked={unbooked.length}
bookedExVat={bookedExVat}
bookedVat={bookedVat}
bookedTotal={bookedTotal}
shared={shared.map((l) => ({ machineCode: l.bookedCode || l.machine_code, others: multiSite.get(l.id) ?? [] }))}
hasProblem={hasProblem}
isOpen={isOpen}
onToggle={() => setOpen(isOpen ? null : inv.id)}
@ -129,16 +119,12 @@ export function InvoiceList({
}
function InvoiceRow({
invoice, lineCount, unbooked, bookedExVat, bookedVat, bookedTotal, shared,
invoice, lineCount, unbooked,
hasProblem, isOpen, onToggle, expanded,
}: {
invoice: Invoice;
lineCount: number;
unbooked: number;
bookedExVat: number;
bookedVat: number;
bookedTotal: number;
shared: { machineCode: string; others: string[] }[];
hasProblem: boolean;
isOpen: boolean;
onToggle: () => void;
@ -195,35 +181,17 @@ function InvoiceRow({
{ZAR2(invoice.total_incl)}
</td>
<td className="py-2 px-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{hasProblem ? (
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{unbooked} unbooked
</span>
) : (
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
All booked
</span>
)}
{shared.length > 0 && (
<span
title="The same machine hired to more than one site this month — normal, each hire books to that machine."
className="text-xs text-[#8a6420] dark:text-[#EDAE49] flex items-start gap-1"
>
<Repeat className="w-3 h-3 mt-0.5 shrink-0" />
<span>
{shared.length === 1 ? "1 machine" : `${shared.length} machines`} also used on{" "}
{[...new Set(shared.flatMap((d) => d.others))].join(", ")}
</span>
</span>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
booked {ZAR2(bookedTotal)}
{BOOK_INCLUSIVE_VAT && bookedVat > 0 && <> · {ZAR2(bookedVat)} VAT</>}
{hasProblem ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{unbooked} unbooked
</span>
</div>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
All booked
</span>
)}
</td>
</tr>