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>
This commit is contained in:
Dave 2026-10-21 20:58:33 +02:00
parent 54005e8d10
commit 1c48c74bd2
4 changed files with 271 additions and 162 deletions

View file

@ -11,6 +11,7 @@ import AreaGroup from '@/components/AreaGroup';
import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import ItemDetailModal from '@/components/ItemDetailModal';
import InvoiceMatchHost from '@/components/InvoiceMatchHost';
import {
getMyAssignedItems,
getMyAssignedStockItems,
@ -910,6 +911,15 @@ const loadItems = useCallback(async (showLoading = true) => {
onClose={() => { setShowDetailModal(false); setSelectedItem(null); }}
/>
{/* Beta: invoice match helper (renders nothing unless it is run) */}
<InvoiceMatchHost
onCollected={(ids) => {
setItems((prev) => prev.filter((i) => !ids.includes(i.id)));
showToast(`Invoice matched · ${ids.length} item${ids.length === 1 ? '' : 's'} marked collected`);
}}
onRestored={() => { void loadItems(false); }}
/>
{/* Toast */}
{toast && (
<div

View file

@ -1,17 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { ScanSearch, Loader2, CheckCircle2, AlertTriangle, XCircle, HelpCircle } from 'lucide-react';
import BottomSheet from '@/components/BottomSheet';
import { useEffect, useRef, useState } from 'react';
import { ScanSearch } from 'lucide-react';
import { RUN_EVENT, invoiceCheckBase } from '@/lib/invoiceCheck';
import { cn } from '@/lib/utils';
import {
checkInvoice,
invoiceCheckBase,
readSeen,
rememberSeen,
type InvoiceCheckResult,
type Verdict,
} from '@/lib/invoiceCheck';
interface InvoiceCheckProps {
poNumber: string;
@ -20,136 +12,45 @@ interface InvoiceCheckProps {
size?: 'sm' | 'lg';
}
const STYLE: Record<Verdict, { icon: typeof CheckCircle2; row: string; label: string }> = {
green: { icon: CheckCircle2, row: 'text-[#125a3e]', label: 'Matches' },
amber: { icon: AlertTriangle, row: 'text-[#8a5a02]', label: 'Check' },
red: { icon: XCircle, row: 'text-[#cf2d56]', label: 'Mismatch' },
unchecked: { icon: HelpCircle, row: 'text-[rgba(38,37,30,0.55)]', label: 'Not checked' },
};
// Beta helper. Renders nothing unless the driver switched it on in
// Settings → Beta, so the normal PO buttons are untouched. It only reads:
// tapping "Check invoice" asks WFDASH to compare the photo with the lines
// collected on this PO and shows the answer. It never blocks anything.
// Beta helper trigger. Renders nothing unless the driver switched it on in
// Settings → Beta, so the normal PO buttons are untouched. It asks
// InvoiceMatchHost (on the page) to match the invoice, either when the driver
// taps the button or automatically right after a new invoice page uploads.
export default function InvoiceCheck({ poNumber, pages, size = 'sm' }: InvoiceCheckProps) {
const [enabled, setEnabled] = useState(false);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<InvoiceCheckResult | null>(null);
const [error, setError] = useState('');
const [seen, setSeen] = useState<'ok' | 'override' | undefined>();
const prevPages = useRef<number | null>(null);
const trigger = () => window.dispatchEvent(new CustomEvent(RUN_EVENT, { detail: { poNumber } }));
useEffect(() => {
setEnabled(Boolean(invoiceCheckBase()));
setSeen(readSeen()[poNumber]);
}, [poNumber]);
}, []);
// A new invoice page just went up: check it straight away.
useEffect(() => {
const before = prevPages.current;
prevPages.current = pages;
if (before !== null && pages > before && poNumber && invoiceCheckBase()) trigger();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pages]);
if (!enabled || !poNumber || pages === 0) return null;
const run = async () => {
if (busy) return;
setOpen(true);
setBusy(true);
setError('');
setResult(null);
try {
setResult(await checkInvoice(poNumber));
} catch (e) {
setError(e instanceof Error ? e.message : 'Check failed');
} finally {
setBusy(false);
}
};
const finish = () => {
if (result) {
const how = result.all_green ? 'ok' : 'override';
rememberSeen(poNumber, how);
setSeen(how);
}
setOpen(false);
};
const lg = size === 'lg';
const btn = lg
? 'h-12 w-full rounded-xl text-[15px] font-semibold flex items-center justify-center gap-2 touch-manipulation'
: 'h-9 px-3 rounded-lg text-[13px] font-semibold flex items-center gap-1.5 touch-manipulation';
return (
<>
<button
type="button"
onClick={run}
aria-label={`Check the invoice for PO ${poNumber} (beta)`}
className={cn(btn, 'bg-[#efeaf7] text-[#6c5a99] active:bg-[#ded5ef]')}
>
<ScanSearch size={lg ? 18 : 14} aria-hidden="true" />
<span>{lg ? `Check invoice (beta)${seen ? ' · seen' : ''}` : 'Check'}</span>
</button>
{open && (
<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}. You can carry on as normal.
</p>
)}
{result && (
<>
{result.lines.length === 0 ? (
<p className="text-[15px] text-[#26251e] py-3">No collected lines on this PO to check yet.</p>
) : (
<ul className="divide-y divide-[rgba(38,37,30,0.08)]">
{result.lines.map((l) => {
const st = STYLE[l.verdict];
const Icon = st.icon;
return (
<li key={l.id} className="py-3 flex gap-3">
<Icon size={20} className={cn('flex-shrink-0 mt-0.5', st.row)} aria-hidden="true" />
<div className="min-w-0">
<p className="text-[14px] text-[#26251e] leading-snug break-words">
{l.description || 'Item'} <span className="text-[rgba(38,37,30,0.55)]">× {l.qty_collected}</span>
</p>
<p className={cn('text-[12px] font-semibold', st.row)}>{st.label}</p>
{l.reason && <p className="text-[12px] text-[rgba(38,37,30,0.7)] break-words">{l.reason}</p>}
</div>
</li>
);
})}
</ul>
)}
{result.error && (
<p className="text-[12px] text-[#8a5a02] mt-2">Note: {result.error}</p>
)}
<button
type="button"
onClick={finish}
className={cn(
'mt-3 h-12 w-full rounded-xl text-[15px] font-semibold touch-manipulation',
result.all_green ? 'bg-[#18714f] text-white' : 'bg-[#26251e] text-white'
)}
>
{result.all_green ? 'All good — continue' : 'I’ve checked — continue anyway'}
</button>
</>
)}
{!busy && (
<button
type="button"
onClick={() => setOpen(false)}
className="mt-2 h-11 w-full rounded-xl text-[14px] font-semibold text-[#6c5a99] touch-manipulation"
>
{result ? 'Close' : 'Close — carry on as normal'}
</button>
)}
</BottomSheet>
<button
type="button"
onClick={trigger}
aria-label={`Match the invoice for PO ${poNumber} to your items (beta)`}
className={cn(
lg
? 'h-12 w-full rounded-xl text-[15px] font-semibold flex items-center justify-center gap-2 touch-manipulation'
: 'h-9 px-3 rounded-lg text-[13px] font-semibold flex items-center gap-1.5 touch-manipulation',
'bg-[#efeaf7] text-[#6c5a99] active:bg-[#ded5ef]',
)}
</>
>
<ScanSearch size={lg ? 18 : 14} aria-hidden="true" />
<span>{lg ? 'Match invoice (beta)' : 'Match'}</span>
</button>
);
}

View file

@ -0,0 +1,185 @@
'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>
);
}

View file

@ -1,28 +1,38 @@
// Beta helper: ask WFDASH whether the invoice photo matches what the driver
// collected on a PO. Advisory only — nothing here writes to PocketBase or
// changes how collecting works. Off unless the driver switches it on in
// Settings.
// Beta helper: photograph the invoice and let WFDASH match it against
// everything assigned to this driver at that supplier. Exact matches (same
// product, invoice quantity = ordered quantity) are marked collected through
// the same writes the bulk-collect button uses, and can be undone.
// Off unless the driver switches it on in Settings → Beta.
import { pb, getSettings } from './pocketbase';
import { pb, getSettings, updateStockCollectionStatus } from './pocketbase';
export type Verdict = 'green' | 'amber' | 'red' | 'unchecked';
export interface CheckedLine {
export interface MatchedLine {
id: string;
kind: 'customer' | 'stock';
po_number: string;
description: string;
quantity: number;
previous: Record<string, unknown>;
}
export interface ReviewLine {
id: string;
kind: 'customer' | 'stock';
po_number: string;
description: string;
qty_collected: number;
verdict: Verdict;
reason: string;
}
export interface InvoiceCheckResult {
export interface InvoiceMatchResult {
po_number: string;
lines: CheckedLine[];
all_green: boolean;
matched: MatchedLine[];
review: ReviewLine[];
error: string;
}
/** Fired by the PO buttons; InvoiceMatchHost (once per page) does the work. */
export const RUN_EVENT = 'invoice-match:run';
/** The WFDASH address to call, or '' when the helper is off or not set up. */
export function invoiceCheckBase(): string {
const s = getSettings();
@ -31,10 +41,10 @@ export function invoiceCheckBase(): string {
return url.replace(/\/+$/, '');
}
export async function checkInvoice(poNumber: string): Promise<InvoiceCheckResult> {
export async function matchInvoice(poNumber: string): Promise<InvoiceMatchResult> {
const base = invoiceCheckBase();
if (!base) throw new Error('Invoice check is not set up (Settings → Beta).');
const res = await fetch(`${base}/api/v1/driver/collection-check`, {
const res = await fetch(`${base}/api/v1/driver/invoice-match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pb.authStore.token}` },
body: JSON.stringify({ po_number: poNumber }),
@ -42,25 +52,28 @@ export async function checkInvoice(poNumber: string): Promise<InvoiceCheckResult
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Check failed (${res.status})`);
return body as InvoiceCheckResult;
return body as InvoiceMatchResult;
}
// Remembered per PO on this device: the driver looked at the result and
// carried on. Purely a note for the driver; it blocks and unlocks nothing.
const SEEN_KEY = 'wfdash-driver:invoice-check-seen';
export function readSeen(): Record<string, 'ok' | 'override'> {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) || '{}');
} catch {
return {};
/** Mark one matched line collected, exactly as bulkCollect / the stock modal do. */
export async function collectMatched(line: MatchedLine): Promise<void> {
if (line.kind === 'stock') {
await updateStockCollectionStatus(line.id, { qty_collected: line.quantity, expectedQty: line.quantity });
return;
}
const now = new Date().toISOString();
await pb.collection('order_items').update(line.id, {
item_status: 'collected',
drv_status: 'collected',
drv_collected_at: now,
qty_collected: line.quantity,
collected_date: now,
});
}
export function rememberSeen(poNumber: string, how: 'ok' | 'override') {
try {
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...readSeen(), [poNumber]: how }));
} catch {
// Storage unavailable: the note is optional.
}
/** Put a matched line back to how it was before the auto-collect. */
export async function undoMatched(line: MatchedLine): Promise<void> {
await pb
.collection(line.kind === 'stock' ? 'stock_purchase_items' : 'order_items')
.update(line.id, line.previous);
}