Invoices: confirm a successful import with a toast
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) <noreply@anthropic.com>
This commit is contained in:
parent
10d257e592
commit
fe4b1fc3dd
2 changed files with 108 additions and 4 deletions
|
|
@ -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 | { 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 }>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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() {
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
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} 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 */}
|
||||
|
|
|
|||
80
components/Toast.tsx
Normal file
80
components/Toast.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed z-[60] inset-x-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:w-auto sm:max-w-sm
|
||||
transition-all duration-200 ease-out
|
||||
${leaving ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-xl shadow-xl border backdrop-blur ${
|
||||
tone === "success"
|
||||
? "bg-white dark:bg-[#131a21] border-[#00798C]/30"
|
||||
: "bg-white dark:bg-[#131a21] border-[#D1495B]/30"
|
||||
}`}
|
||||
>
|
||||
{tone === "success" ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-[#00798C] shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-[#D1495B] shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`text-sm font-semibold ${
|
||||
tone === "success" ? "text-[#00798C]" : "text-[#D1495B]"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-0.5 break-words">{detail}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Dismiss"
|
||||
className="grid place-items-center w-11 h-11 -mr-2 -my-1 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue