WFDASHDRIVER-APP/components/InvoiceMatchHost.tsx

266 lines
11 KiB
TypeScript
Raw Permalink Normal View History

'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>
);
}