WFDASHDRIVER-APP/components/InvoiceMatchHost.tsx
Dave 1c48c74bd2 feat: beta invoice match marks matching assigned items collected
After an invoice photo, exact matches are marked collected (with undo);
uncertain ones are listed. Still off unless enabled in Settings > Beta.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:58:33 +02:00

185 lines
7.1 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,
undoMatched,
type InvoiceMatchResult,
type MatchedLine,
} 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;
}
// 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 }: 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 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([]);
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();
};
if (!open) return null;
return (
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={() => setOpen(false)}>
{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>
</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={() => setOpen(false)}
className="mt-1 h-12 w-full rounded-xl text-[15px] font-semibold bg-[#26251e] text-white touch-manipulation"
>
Done
</button>
)}
</BottomSheet>
);
}