2026-10-21 18:58:33 +00:00
|
|
|
|
'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,
|
2026-10-22 02:14:47 +00:00
|
|
|
|
saveReviewNote,
|
2026-10-21 18:58:33 +00:00
|
|
|
|
undoMatched,
|
|
|
|
|
|
type InvoiceMatchResult,
|
|
|
|
|
|
type MatchedLine,
|
2026-10-22 02:14:47 +00:00
|
|
|
|
type ReviewLine,
|
2026-10-21 18:58:33 +00:00
|
|
|
|
} 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;
|
2026-10-26 08:54:09 +00:00
|
|
|
|
/** The sheet closed: the page asks about anything still to collect. */
|
|
|
|
|
|
onClosed?: () => void;
|
2026-10-21 18:58:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-10-26 08:54:09 +00:00
|
|
|
|
export default function InvoiceMatchHost({ onCollected, onRestored, onClosed }: InvoiceMatchHostProps) {
|
2026-10-21 18:58:33 +00:00
|
|
|
|
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('');
|
2026-10-22 02:14:47 +00:00
|
|
|
|
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[]>([]);
|
2026-10-21 18:58:33 +00:00
|
|
|
|
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([]);
|
2026-10-22 02:14:47 +00:00
|
|
|
|
setNoteFor(null);
|
|
|
|
|
|
setSavedNotes([]);
|
2026-10-21 18:58:33 +00:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-22 02:14:47 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
2026-10-26 08:54:09 +00:00
|
|
|
|
const close = () => {
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
onClosed?.();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
|
return (
|
2026-10-26 08:54:09 +00:00
|
|
|
|
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={close}>
|
2026-10-21 18:58:33 +00:00
|
|
|
|
{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>
|
2026-10-22 02:14:47 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-10-21 18:58:33 +00:00
|
|
|
|
</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"
|
2026-10-26 08:54:09 +00:00
|
|
|
|
onClick={close}
|
2026-10-21 18:58:33 +00:00
|
|
|
|
className="mt-1 h-12 w-full rounded-xl text-[15px] font-semibold bg-[#26251e] text-white touch-manipulation"
|
|
|
|
|
|
>
|
|
|
|
|
|
Done
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</BottomSheet>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|