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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-21 19:38:38 +02:00
parent 48e081a080
commit 54005e8d10
6 changed files with 284 additions and 0 deletions

View file

@ -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=

View file

@ -290,6 +290,57 @@ export default function SettingsPage() {
</div>
</section>
{/* Beta helpers */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<Shield size={14} className="text-[#f54e00]" />
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
Beta
</h2>
</div>
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="pr-3">
<span className="text-sm font-semibold text-[#26251e] block">Invoice check</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">
Adds a Check invoice button after you photograph an invoice. Only advises; nothing else changes.
</span>
</div>
<button
onClick={() => updateSetting('aiCheckEnabled', !settings.aiCheckEnabled)}
role="switch"
aria-checked={settings.aiCheckEnabled}
aria-label="Invoice check"
className={cn(
'w-[52px] h-[30px] rounded-full transition-colors relative flex-shrink-0',
settings.aiCheckEnabled ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
)}
>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.aiCheckEnabled ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
{settings.aiCheckEnabled && (
<label className="block">
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">WFDASH address</span>
<input
type="url"
inputMode="url"
value={settings.aiCheckUrl}
onChange={(e) => updateSetting('aiCheckUrl', e.target.value.trim())}
placeholder="https://your-wfdash-address"
className="mt-1 w-full h-11 rounded-xl border border-[rgba(38,37,30,0.15)] bg-white px-3 text-[14px]"
/>
</label>
)}
</div>
</div>
</section>
{/* About */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">

155
components/InvoiceCheck.tsx Normal file
View file

@ -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<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.
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>();
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 (
<>
<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>
)}
</>
);
}

View file

@ -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 && (
<InvoiceCheck poNumber={purchaseOrder.po_number} pages={pages} size={size} />
)}
</div>
);
}

66
lib/invoiceCheck.ts Normal file
View file

@ -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<InvoiceCheckResult> {
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<string, 'ok' | 'override'> {
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.
}
}

View file

@ -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 = [