WFDASHDRIVER-APP/components/InvoiceCheck.tsx
Dave 54005e8d10 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>
2026-10-21 19:38:38 +02:00

155 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)}
</>
);
}