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>
155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
'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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|