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:
parent
30c30ed821
commit
c2faca98bd
3 changed files with 141 additions and 3 deletions
36
app/page.tsx
36
app/page.tsx
|
|
@ -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
|
||||
|
|
|
|||
95
components/CollectPrompt.tsx
Normal file
95
components/CollectPrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue