feat: ask to collect a PO's items after its invoice photo uploads

An invoice photo collects nothing, and the green invoice button made it
look done, so lines stayed "assigned" and never reached Collection
Confirm (EWSS and G-Man on 2026-10-26). After an upload the driver now
gets "Collect these items?" with every item ticked. Without the AI match
it opens straight away; with it, once the match sheet closes, listing
only what the match left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-26 10:54:09 +02:00
parent 30c30ed821
commit c2faca98bd
3 changed files with 141 additions and 3 deletions

View file

@ -12,6 +12,8 @@ import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import ItemDetailModal from '@/components/ItemDetailModal';
import InvoiceMatchHost from '@/components/InvoiceMatchHost';
import CollectPrompt from '@/components/CollectPrompt';
import { invoiceCheckBase } from '@/lib/invoiceCheck';
import {
getMyAssignedItems,
getMyAssignedStockItems,
@ -108,6 +110,12 @@ export default function CollectionsPage() {
const [showDetailModal, setShowDetailModal] = useState(false);
const [showProblemModal, setShowProblemModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
// "Collect these items?" after an invoice photo. With the AI match on it
// waits in pendingPrompt until the match sheet closes, so it only lists
// what the match left behind.
type PromptState = { poLabel: string; ids: string[] };
const [collectPrompt, setCollectPrompt] = useState<PromptState | null>(null);
const pendingPrompt = useRef<PromptState | null>(null);
// One layout for every driver: compact rows, grouped supplier → PO.
// The supplier/invoice/area and compact/detailed switches were removed
// on 2026-10-18 — six layouts behind small icons just confused drivers.
@ -393,6 +401,13 @@ const loadItems = useCallback(async (showLoading = true) => {
const files = await uploadInvoicePage({ target, poNumber, page, file });
setItems((prev) => withTargetFiles(prev, target, files));
showToast(`Invoice page ${page} uploaded${poNumber ? ` · PO ${poNumber}` : ''}`);
// The photo collects nothing by itself: ask, so the lines reach
// Collection Confirm whether or not the AI match runs.
if (statusFilter === 'assigned') {
const prompt = { poLabel: poNumber ? `PO ${poNumber}` : 'this PO', ids: poItems.map((i) => i.id) };
if (poNumber && invoiceCheckBase()) pendingPrompt.current = prompt;
else setCollectPrompt(prompt);
}
} catch (err) {
console.error('Invoice photo upload failed:', err);
const reason = err instanceof Error ? err.message : 'unknown error';
@ -918,8 +933,29 @@ const loadItems = useCallback(async (showLoading = true) => {
showToast(`Invoice matched · ${ids.length} item${ids.length === 1 ? '' : 's'} marked collected`);
}}
onRestored={() => { void loadItems(false); }}
onClosed={() => {
if (pendingPrompt.current) setCollectPrompt(pendingPrompt.current);
pendingPrompt.current = null;
}}
/>
{collectPrompt && (() => {
const left = items.filter((i) => collectPrompt.ids.includes(i.id));
if (left.length === 0) return null;
return (
<CollectPrompt
key={collectPrompt.ids.join(',')}
poLabel={collectPrompt.poLabel}
items={left}
onClose={() => setCollectPrompt(null)}
onCollect={(chosen) => {
setCollectPrompt(null);
void handleBulkCollect(collectPrompt.poLabel, chosen);
}}
/>
);
})()}
{/* Toast */}
{toast && (
<div

View file

@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { Check } from 'lucide-react';
import BottomSheet from '@/components/BottomSheet';
import { getItemDisplayQty } from '@/lib/pocketbase';
import type { OrderItem } from '@/lib/types';
import { cn } from '@/lib/utils';
interface CollectPromptProps {
poLabel: string;
/** The PO's items still on the driver's list. */
items: OrderItem[];
onCollect: (items: OrderItem[]) => void;
onClose: () => void;
}
// Shown right after an invoice photo uploads. The photo alone collects
// nothing, and the green invoice button made it look done, so drivers left
// lines "assigned" and off Collection Confirm. Works with or without the AI
// match: it only lists what is still on the list. Every item starts ticked;
// a short or different item is unticked and collected on its own card.
export default function CollectPrompt({ poLabel, items, onCollect, onClose }: CollectPromptProps) {
const [picked, setPicked] = useState<Set<string>>(() => new Set(items.map((i) => i.id)));
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const chosen = items.filter((i) => picked.has(i.id));
return (
<BottomSheet title="Invoice saved · collect these items?" subtitle={poLabel} onClose={onClose}>
<p className="text-[14px] text-[#26251e] mb-2">
The photo is saved, but the items aren’t collected yet. Tick what you picked up.
</p>
<ul className="divide-y divide-[rgba(38,37,30,0.08)] mb-2">
{items.map((item) => {
const on = picked.has(item.id);
return (
<li key={item.id}>
<button
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggle(item.id)}
className="w-full py-2.5 flex items-start gap-3 text-left touch-manipulation"
>
<span
className={cn(
'w-6 h-6 rounded-md border flex items-center justify-center flex-shrink-0 mt-0.5',
on ? 'bg-[#1f8a65] border-[#1f8a65]' : 'bg-white border-[rgba(38,37,30,0.3)]',
)}
aria-hidden="true"
>
{on && <Check size={16} className="text-white" />}
</span>
<span className="flex-1 min-w-0 text-[14px] text-[#26251e] leading-snug break-words">
{item.description}
<span className="text-[rgba(38,37,30,0.55)]"> × {getItemDisplayQty(item)}</span>
</span>
</button>
</li>
);
})}
</ul>
<p className="text-[12px] text-[rgba(38,37,30,0.6)] mb-3">
Short or different from the PO? Untick it and use Collect on that item to enter what you got.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="h-12 flex-1 rounded-xl text-[15px] font-semibold bg-[#efeaf7] text-[#6c5a99] touch-manipulation"
>
Not now
</button>
<button
type="button"
disabled={chosen.length === 0}
onClick={() => onCollect(chosen)}
className="h-12 flex-1 rounded-xl text-[15px] font-semibold bg-[#1f8a65] text-white disabled:opacity-50 touch-manipulation"
>
{chosen.length === items.length && items.length > 1
? `Collect all ${items.length}`
: `Collect ${chosen.length} item${chosen.length === 1 ? '' : 's'}`}
</button>
</div>
</BottomSheet>
);
}

View file

@ -19,13 +19,15 @@ interface InvoiceMatchHostProps {
onCollected: (ids: string[]) => void;
/** Lines put back: reload the driver's list. */
onRestored: () => void;
/** The sheet closed: the page asks about anything still to collect. */
onClosed?: () => void;
}
// Beta helper, mounted once on the driver page. Renders nothing until a PO's
// "Check invoice" runs. It marks only exact matches collected (same product,
// invoice quantity = ordered quantity); everything uncertain is listed for the
// driver and left alone. Every auto-collected line can be undone.
export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMatchHostProps) {
export default function InvoiceMatchHost({ onCollected, onRestored, onClosed }: InvoiceMatchHostProps) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [poNumber, setPoNumber] = useState('');
@ -125,8 +127,13 @@ export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMat
if (!open) return null;
const close = () => {
setOpen(false);
onClosed?.();
};
return (
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={() => setOpen(false)}>
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={close}>
{busy && (
<p className="flex items-center gap-2 text-[15px] text-[#26251e] py-4">
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
@ -247,7 +254,7 @@ export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMat
{!busy && (
<button
type="button"
onClick={() => setOpen(false)}
onClick={close}
className="mt-1 h-12 w-full rounded-xl text-[15px] font-semibold bg-[#26251e] text-white touch-manipulation"
>
Done