From fe4b1fc3dd8400c4b57aac23f3b7a8449e257579 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 2 Oct 2026 14:46:55 +0200 Subject: [PATCH] Invoices: confirm a successful import with a toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the dialog on success left the OK click completely silent — the user had to go hunting the table for the new row to find out whether anything had happened. A toast says so plainly and then gets out of the way. It carries the useful part: invoice number, line count, billed total, how many lines booked, and — the bit that actually needs action — the machine codes that could not be matched, named rather than left for the user to infer from the badge. Auto-dismisses after six seconds, dismissible by hand, and clears when the dialog is reopened. Announced via role=status / aria-live=polite rather than assertively: a confirmation is not urgent and should not cut into whatever a screen reader is currently saying. No toast library — the app needs exactly one, and a dependency would carry far more surface than that. Sits above the dialog's z-50, full-width on mobile and bottom-right from sm up, with a 44px dismiss target. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/InvoiceIntake.tsx | 32 +++++++++++++-- components/Toast.tsx | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 components/Toast.tsx diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx index 931f36b..4273837 100644 --- a/components/InvoiceIntake.tsx +++ b/components/InvoiceIntake.tsx @@ -5,6 +5,8 @@ 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 @@ -15,6 +17,10 @@ export function AddInvoiceButton() { const [dragging, setDragging] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(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); const inputRef = useRef(null); const router = useRouter(); @@ -74,9 +80,20 @@ export function AddInvoiceButton() { 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. Anything needing attention is already flagged on the - // invoice's row in the table ("N unbooked"), so there is nothing to read here. - // A FAILURE stays open, because the reason is the whole point of the inbox. + // 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({ + 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(); @@ -99,13 +116,20 @@ export function AddInvoiceButton() { <> + {toast && ( + setToast(null)} /> + )} + {open && (
{/* Backdrop */} diff --git a/components/Toast.tsx b/components/Toast.tsx new file mode 100644 index 0000000..a68e2f1 --- /dev/null +++ b/components/Toast.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CheckCircle2, X, AlertTriangle } from "lucide-react"; + +// A minimal toast. Deliberately not a dependency: the app needs exactly one, and a +// full toast library would ship far more surface than that justifies. +// +// Announced politely rather than assertively — a confirmation is not urgent, and it +// should not interrupt whatever a screen reader is currently saying. +export function Toast({ + message, + detail, + tone = "success", + onDismiss, + duration = 6000, +}: { + message: string; + detail?: string; + tone?: "success" | "error"; + onDismiss: () => void; + duration?: number; +}) { + const [leaving, setLeaving] = useState(false); + + const close = () => { + setLeaving(true); + setTimeout(onDismiss, 200); // let the fade-out run before unmounting + }; + + useEffect(() => { + const t = setTimeout(close, duration); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [message, detail, duration]); + + return ( +
+
+ {tone === "success" ? ( + + ) : ( + + )} +
+
+ {message} +
+ {detail && ( +
{detail}
+ )} +
+ +
+
+ ); +} \ No newline at end of file