WFDASHDRIVER-APP/components/InvoiceMatchHost.tsx
Dave c2faca98bd feat: ask to collect a PO's items after its invoice photo uploads
An invoice photo collects nothing, and the green invoice button made it
look done, so lines stayed "assigned" and never reached Collection
Confirm (EWSS and G-Man on 2026-10-26). After an upload the driver now
gets "Collect these items?" with every item ticked. Without the AI match
it opens straight away; with it, once the match sheet closes, listing
only what the match left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:54:09 +02:00

265 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertTriangle, Undo2 } from 'lucide-react';
import BottomSheet from '@/components/BottomSheet';
import {
RUN_EVENT,
collectMatched,
matchInvoice,
saveReviewNote,
undoMatched,
type InvoiceMatchResult,
type MatchedLine,
type ReviewLine,
} from '@/lib/invoiceCheck';
interface InvoiceMatchHostProps {
/** Lines just marked collected: drop them from the driver's list. */
onCollected: (ids: string[]) => void;
/** Lines put back: reload the driver's list. */
onRestored: () => void;
/** The sheet closed: the page asks about anything still to collect. */
onClosed?: () => void;
}
// Beta helper, mounted once on the driver page. Renders nothing until a PO's
// "Check invoice" runs. It marks only exact matches collected (same product,
// invoice quantity = ordered quantity); everything uncertain is listed for the
// driver and left alone. Every auto-collected line can be undone.
export default function InvoiceMatchHost({ onCollected, onRestored, onClosed }: InvoiceMatchHostProps) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [poNumber, setPoNumber] = useState('');
const [result, setResult] = useState<InvoiceMatchResult | null>(null);
const [collected, setCollected] = useState<MatchedLine[]>([]);
const [failedIds, setFailedIds] = useState<string[]>([]);
const [error, setError] = useState('');
const [noteFor, setNoteFor] = useState<string | null>(null);
const [noteText, setNoteText] = useState('');
const [noteBusy, setNoteBusy] = useState(false);
const [noteError, setNoteError] = useState('');
const [savedNotes, setSavedNotes] = useState<string[]>([]);
const running = useRef(false);
const run = useCallback(
async (po: string) => {
if (running.current) return;
running.current = true;
setPoNumber(po);
setOpen(true);
setBusy(true);
setError('');
setResult(null);
setCollected([]);
setFailedIds([]);
setNoteFor(null);
setSavedNotes([]);
try {
const r = await matchInvoice(po);
setResult(r);
const done: MatchedLine[] = [];
const failed: string[] = [];
for (const line of r.matched) {
try {
await collectMatched(line);
done.push(line);
} catch (e) {
console.warn('[invoice-match] collect failed for', line.id, e);
failed.push(line.id);
}
}
setCollected(done);
setFailedIds(failed);
if (done.length > 0) onCollected(done.map((l) => l.id));
} catch (e) {
setError(e instanceof Error ? e.message : 'Check failed');
} finally {
setBusy(false);
running.current = false;
}
},
[onCollected],
);
useEffect(() => {
const handler = (e: Event) => {
const po = (e as CustomEvent<{ poNumber?: string }>).detail?.poNumber;
if (po) void run(po);
};
window.addEventListener(RUN_EVENT, handler);
return () => window.removeEventListener(RUN_EVENT, handler);
}, [run]);
const undo = async (lines: MatchedLine[]) => {
const kept: MatchedLine[] = [];
for (const line of lines) {
try {
await undoMatched(line);
} catch (e) {
console.warn('[invoice-match] undo failed for', line.id, e);
kept.push(line);
}
}
setCollected((prev) => prev.filter((l) => kept.some((k) => k.id === l.id) || !lines.some((x) => x.id === l.id)));
onRestored();
};
const saveNote = async (line: ReviewLine) => {
if (!noteText.trim()) {
setNoteError('Write the reason first.');
return;
}
setNoteBusy(true);
setNoteError('');
try {
await saveReviewNote(line, noteText);
setSavedNotes((prev) => [...prev, line.id]);
setNoteFor(null);
setNoteText('');
} catch (e) {
console.warn('[invoice-match] note failed for', line.id, e);
setNoteError('Could not save the note. Try again.');
} finally {
setNoteBusy(false);
}
};
if (!open) return null;
const close = () => {
setOpen(false);
onClosed?.();
};
return (
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={close}>
{busy && (
<p className="flex items-center gap-2 text-[15px] text-[#26251e] py-4">
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
Reading the invoice… this can take up to a minute.
</p>
)}
{error && (
<p className="text-[15px] text-[#cf2d56] py-3">
Couldn’t check: {error}. Nothing was changed — carry on as normal.
</p>
)}
{result && !busy && (
<>
{collected.length > 0 && (
<section className="mb-3">
<p className="text-[13px] font-bold text-[#125a3e] mb-1">
Marked collected ({collected.length})
</p>
<ul className="divide-y divide-[rgba(38,37,30,0.08)]">
{collected.map((l) => (
<li key={l.id} className="py-2.5 flex items-start gap-3">
<CheckCircle2 size={20} className="text-[#125a3e] flex-shrink-0 mt-0.5" aria-hidden="true" />
<p className="flex-1 min-w-0 text-[14px] text-[#26251e] leading-snug break-words">
{l.description} <span className="text-[rgba(38,37,30,0.55)]">× {l.quantity}</span>
{l.po_number !== poNumber && (
<span className="block text-[12px] text-[rgba(38,37,30,0.55)]">PO {l.po_number}</span>
)}
</p>
<button
type="button"
onClick={() => void undo([l])}
className="h-9 px-3 rounded-lg text-[12px] font-semibold text-[#6c5a99] bg-[#efeaf7] flex items-center gap-1 touch-manipulation"
>
<Undo2 size={13} aria-hidden="true" /> Undo
</button>
</li>
))}
</ul>
</section>
)}
{failedIds.length > 0 && (
<p className="text-[13px] text-[#8a5a02] mb-3">
{failedIds.length} matching item{failedIds.length === 1 ? '' : 's'} couldn’t be marked collected — collect {failedIds.length === 1 ? 'it' : 'them'} as normal.
</p>
)}
{result.review.length > 0 && (
<section className="mb-3">
<p className="text-[13px] font-bold text-[#8a5a02] mb-1">Check these yourself ({result.review.length})</p>
<ul className="divide-y divide-[rgba(38,37,30,0.08)]">
{result.review.map((l) => (
<li key={l.id} className="py-2.5 flex gap-3">
<AlertTriangle size={20} className="text-[#8a5a02] flex-shrink-0 mt-0.5" aria-hidden="true" />
<div className="min-w-0">
<p className="text-[14px] text-[#26251e] leading-snug break-words">{l.description}</p>
<p className="text-[12px] text-[rgba(38,37,30,0.7)] break-words">{l.reason}</p>
{savedNotes.includes(l.id) ? (
<p className="text-[12px] font-semibold text-[#125a3e] mt-1">Note saved</p>
) : noteFor === l.id ? (
<div className="mt-2">
<label htmlFor={`note-${l.id}`} className="block text-[12px] font-semibold text-[#26251e] mb-1">
Ask the supplier why it differs, then write their answer. We can’t be invoiced for what we haven’t received.
</label>
<textarea
id={`note-${l.id}`}
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
className="w-full rounded-lg border border-[rgba(38,37,30,0.2)] bg-white p-2 text-[14px] text-[#26251e]"
placeholder="Reason from the supplier…"
/>
{noteError && <p className="text-[12px] text-[#cf2d56] mt-1">{noteError}</p>}
<div className="flex gap-2 mt-2">
<button
type="button"
disabled={noteBusy}
onClick={() => void saveNote(l)}
className="h-10 px-4 rounded-lg text-[13px] font-semibold bg-[#26251e] text-white disabled:opacity-60 touch-manipulation"
>
{noteBusy ? 'Saving…' : 'Save note'}
</button>
<button
type="button"
disabled={noteBusy}
onClick={() => { setNoteFor(null); setNoteText(''); setNoteError(''); }}
className="h-10 px-4 rounded-lg text-[13px] font-semibold bg-[#efeaf7] text-[#6c5a99] touch-manipulation"
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => { setNoteFor(l.id); setNoteText(''); setNoteError(''); }}
className="mt-1.5 h-9 px-3 rounded-lg text-[12px] font-semibold bg-[#efeaf7] text-[#6c5a99] touch-manipulation"
>
Add a note
</button>
)}
</div>
</li>
))}
</ul>
</section>
)}
{result.matched.length === 0 && result.review.length === 0 && (
<p className="text-[15px] text-[#26251e] py-3">
{result.error
? `Couldn’t read the invoice: ${result.error}`
: 'None of your assigned items for this supplier were found on the invoice.'}
</p>
)}
{result.error && (result.matched.length > 0 || result.review.length > 0) && (
<p className="text-[12px] text-[#8a5a02] mb-2">Note: {result.error}</p>
)}
</>
)}
{!busy && (
<button
type="button"
onClick={close}
className="mt-1 h-12 w-full rounded-xl text-[15px] font-semibold bg-[#26251e] text-white touch-manipulation"
>
Done
</button>
)}
</BottomSheet>
);
}