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:
parent
48e081a080
commit
54005e8d10
6 changed files with 284 additions and 0 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
155
components/InvoiceCheck.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
66
lib/invoiceCheck.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue