From 1c48c74bd28153df2e8040cadc2e7978c962d069 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Oct 2026 20:58:33 +0200 Subject: [PATCH] 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 --- app/page.tsx | 10 ++ components/InvoiceCheck.tsx | 163 ++++++---------------------- components/InvoiceMatchHost.tsx | 185 ++++++++++++++++++++++++++++++++ lib/invoiceCheck.ts | 75 +++++++------ 4 files changed, 271 insertions(+), 162 deletions(-) create mode 100644 components/InvoiceMatchHost.tsx diff --git a/app/page.tsx b/app/page.tsx index f261f69..41c9453 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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) */} + { + 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 && (
= { - 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(null); - const [error, setError] = useState(''); - const [seen, setSeen] = useState<'ok' | 'override' | undefined>(); + const prevPages = useRef(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 ( - <> - - - {open && ( - setOpen(false)}> - {busy && ( -

-

- )} - {error && ( -

- Couldn’t check: {error}. You can carry on as normal. -

- )} - {result && ( - <> - {result.lines.length === 0 ? ( -

No collected lines on this PO to check yet.

- ) : ( -
    - {result.lines.map((l) => { - const st = STYLE[l.verdict]; - const Icon = st.icon; - return ( -
  • -
  • - ); - })} -
- )} - {result.error && ( -

Note: {result.error}

- )} - - - )} - {!busy && ( - - )} -
+ ); } diff --git a/components/InvoiceMatchHost.tsx b/components/InvoiceMatchHost.tsx new file mode 100644 index 0000000..774052a --- /dev/null +++ b/components/InvoiceMatchHost.tsx @@ -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(null); + const [collected, setCollected] = useState([]); + const [failedIds, setFailedIds] = useState([]); + 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 ( + setOpen(false)}> + {busy && ( +

+

+ )} + {error && ( +

+ Couldn’t check: {error}. Nothing was changed — carry on as normal. +

+ )} + {result && !busy && ( + <> + {collected.length > 0 && ( +
+

+ Marked collected ({collected.length}) +

+
    + {collected.map((l) => ( +
  • +
  • + ))} +
+
+ )} + {failedIds.length > 0 && ( +

+ {failedIds.length} matching item{failedIds.length === 1 ? '' : 's'} couldn’t be marked collected — collect {failedIds.length === 1 ? 'it' : 'them'} as normal. +

+ )} + {result.review.length > 0 && ( +
+

Check these yourself ({result.review.length})

+
    + {result.review.map((l) => ( +
  • +
  • + ))} +
+
+ )} + {result.matched.length === 0 && result.review.length === 0 && ( +

+ {result.error + ? `Couldn’t read the invoice: ${result.error}` + : 'None of your assigned items for this supplier were found on the invoice.'} +

+ )} + {result.error && (result.matched.length > 0 || result.review.length > 0) && ( +

Note: {result.error}

+ )} + + )} + {!busy && ( + + )} +
+ ); +} diff --git a/lib/invoiceCheck.ts b/lib/invoiceCheck.ts index 3346a8e..a2413b4 100644 --- a/lib/invoiceCheck.ts +++ b/lib/invoiceCheck.ts @@ -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; +} + +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 { +export async function matchInvoice(poNumber: string): Promise { 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 ({})); 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 { - 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 { + 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 { + await pb + .collection(line.kind === 'stock' ? 'stock_purchase_items' : 'order_items') + .update(line.id, line.previous); }