From 54005e8d1062548a18f3a437861ecca53e6cb5d2 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Oct 2026 19:38:38 +0200 Subject: [PATCH] feat: opt-in Check invoice helper (beta, off by default) Settings > Beta switch adds an advisory Check invoice button on POs with an invoice photo. Nothing else in collecting changes. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 1 + app/settings/page.tsx | 51 ++++++++++++ components/InvoiceCheck.tsx | 155 ++++++++++++++++++++++++++++++++++++ components/PoActions.tsx | 5 ++ lib/invoiceCheck.ts | 66 +++++++++++++++ lib/types.ts | 6 ++ 6 files changed, 284 insertions(+) create mode 100644 components/InvoiceCheck.tsx create mode 100644 lib/invoiceCheck.ts diff --git a/.env.example b/.env.example index 344e6ba..90ddc94 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.1 # This env var is embedded at build time (NEXT_PUBLIC_ prefix) # Update this if your PocketBase URL changes +NEXT_PUBLIC_WFDASH_URL= diff --git a/app/settings/page.tsx b/app/settings/page.tsx index e561ef5..d33a0f2 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -290,6 +290,57 @@ export default function SettingsPage() { + {/* Beta helpers */} +
+
+ +

+ Beta +

+
+ +
+
+
+
+ Invoice check + + Adds a Check invoice button after you photograph an invoice. Only advises; nothing else changes. + +
+ +
+ {settings.aiCheckEnabled && ( + + )} +
+
+
+ {/* About */}
diff --git a/components/InvoiceCheck.tsx b/components/InvoiceCheck.tsx new file mode 100644 index 0000000..c2b7911 --- /dev/null +++ b/components/InvoiceCheck.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { ScanSearch, Loader2, CheckCircle2, AlertTriangle, XCircle, HelpCircle } from 'lucide-react'; +import BottomSheet from '@/components/BottomSheet'; +import { cn } from '@/lib/utils'; +import { + checkInvoice, + invoiceCheckBase, + readSeen, + rememberSeen, + type InvoiceCheckResult, + type Verdict, +} from '@/lib/invoiceCheck'; + +interface InvoiceCheckProps { + poNumber: string; + /** Invoice pages already uploaded for this PO; the helper needs at least one. */ + pages: number; + size?: 'sm' | 'lg'; +} + +const STYLE: Record = { + 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. +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>(); + + useEffect(() => { + setEnabled(Boolean(invoiceCheckBase())); + setSeen(readSeen()[poNumber]); + }, [poNumber]); + + 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/PoActions.tsx b/components/PoActions.tsx index 4d99643..0f1284a 100644 --- a/components/PoActions.tsx +++ b/components/PoActions.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from 'react'; import { Camera, FileText, Loader2, Check } from 'lucide-react'; import { OrderItem, PurchaseOrder } from '@/lib/types'; import { buildPoFileUrl, countInvoicePages, invoicePhotoTarget } from '@/lib/poDocs'; +import InvoiceCheck from '@/components/InvoiceCheck'; import { cn } from '@/lib/utils'; interface PoActionsProps { @@ -130,6 +131,10 @@ export default function PoActions({ /> )} + {/* Beta helper: renders nothing unless switched on in Settings. */} + {purchaseOrder?.po_number && ( + + )}
); } diff --git a/lib/invoiceCheck.ts b/lib/invoiceCheck.ts new file mode 100644 index 0000000..3346a8e --- /dev/null +++ b/lib/invoiceCheck.ts @@ -0,0 +1,66 @@ +// 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. + +import { pb, getSettings } from './pocketbase'; + +export type Verdict = 'green' | 'amber' | 'red' | 'unchecked'; + +export interface CheckedLine { + id: string; + kind: 'customer' | 'stock'; + description: string; + qty_collected: number; + verdict: Verdict; + reason: string; +} + +export interface InvoiceCheckResult { + po_number: string; + lines: CheckedLine[]; + all_green: boolean; + error: string; +} + +/** The WFDASH address to call, or '' when the helper is off or not set up. */ +export function invoiceCheckBase(): string { + const s = getSettings(); + if (!s.aiCheckEnabled) return ''; + const url = (s.aiCheckUrl || process.env.NEXT_PUBLIC_WFDASH_URL || '').trim(); + return url.replace(/\/+$/, ''); +} + +export async function checkInvoice(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`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pb.authStore.token}` }, + body: JSON.stringify({ po_number: poNumber }), + signal: AbortSignal.timeout(120_000), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || `Check failed (${res.status})`); + return body as InvoiceCheckResult; +} + +// 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 {}; + } +} + +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. + } +} diff --git a/lib/types.ts b/lib/types.ts index ff9c9d9..a9bae88 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -264,6 +264,10 @@ export interface AppSettings { darkMode: boolean; fontSize: 'small' | 'normal' | 'large'; offlineMode: boolean; + /** Beta: show a "Check invoice" helper on POs. Off by default; changes nothing else. */ + aiCheckEnabled: boolean; + /** WFDASH base URL the helper calls, e.g. https://wfdash.example.com */ + aiCheckUrl: string; } export const DEFAULT_SETTINGS: AppSettings = { @@ -273,6 +277,8 @@ export const DEFAULT_SETTINGS: AppSettings = { darkMode: false, fontSize: 'normal', offlineMode: false, + aiCheckEnabled: false, + aiCheckUrl: '', }; export const PROBLEM_TYPES = [