From de16082ff7e96f90e97eca97e75425f5556f4b3f Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 17 Oct 2026 17:43:35 +0200 Subject: [PATCH] feat: one invoice photo per PO, one-tap PO PDF, save lock, sharper photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Confirm Collection locks while saving ("Saving…") and the page drops a second collect for an item already in flight. Repeat taps on slow data re-ran the save and re-uploaded the photo (7 identical copies on one item on 2026-10-16). - PO headers (and a single-PO supplier's list) get an Invoice button: photograph the supplier invoice once per PO, then "add pg 2", "pg 3". Pages go onto one customer line of the PO as invoice--p.jpg; WFDASH Collection Confirm already gathers photos per PO card. The page count is also kept on the device so it survives lines dropping off. - PO button opens the PO PDF in one tap (shared buildPoFileUrl in lib/poDocs.ts, also used by the item detail modal). - Collect modal says whether the PO's invoice is already uploaded, so the per-item photo is only for problems with the item. - Photos now upload at 2000px / q0.8 (was 1200px / q0.7) so small print on invoices stays readable for the AI checks. - Supplier header counts and detailed view now include items in PO grouping mode (they showed 0 items before). Co-Authored-By: Claude Opus 5.5 --- app/page.tsx | 39 +++++++++++ components/CollectModal.tsx | 45 ++++++++++--- components/ItemDetailModal.tsx | 14 +--- components/PoActions.tsx | 120 +++++++++++++++++++++++++++++++++ components/SupplierGroup.tsx | 58 +++++++++++++--- lib/image.ts | 19 +++--- lib/poDocs.ts | 106 +++++++++++++++++++++++++++++ lib/pocketbase.ts | 2 +- 8 files changed, 363 insertions(+), 40 deletions(-) create mode 100644 components/PoActions.tsx create mode 100644 lib/poDocs.ts diff --git a/app/page.tsx b/app/page.tsx index 24c38d8..9f175a9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -30,6 +30,7 @@ import { bulkProblemStock, } from '@/lib/pocketbase'; import { getCached, setCached } from '@/lib/itemsCache'; +import { countInvoicePages, invoicePhotoTarget, uploadInvoicePage } from '@/lib/poDocs'; import { cn, clusterByProximity } from '@/lib/utils'; import { RefreshCw, @@ -246,7 +247,14 @@ const loadItems = useCallback(async (showLoading = true) => { setTimeout(() => setToast(null), 3000); }; + // Item ids whose collect is still in flight. The modal disables its + // button while saving, but this is the backstop: a second call for + // the same item is dropped instead of re-saving and re-uploading. + const collectingRef = useRef>(new Set()); + const handleCollect = async (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => { + if (collectingRef.current.has(item.id)) return; + collectingRef.current.add(item.id); try { // expectedQty is computed from the same getItemDisplayQty the // modal uses, so the comparison happens against the same number @@ -335,6 +343,29 @@ const loadItems = useCallback(async (showLoading = true) => { } } catch (err) { showToast('Failed to update collection', 'error'); + } finally { + collectingRef.current.delete(item.id); + } + }; + + // One invoice photo per PO, taken from the PO header. Page N goes on + // the same line as page 1 so the office sees the pages together. + const handleAddInvoicePage = async (poItems: OrderItem[], file: File) => { + const target = invoicePhotoTarget(poItems); + if (!target) return; + const poNumber = target.expand?.purchase_order?.po_number ?? ''; + const page = countInvoicePages(poItems) + 1; + try { + const files = await uploadInvoicePage({ target, poNumber, page, file }); + setItems((prev) => + prev.map((i) => (i.id === target.id ? { ...i, order_item_attachements: files } : i)), + ); + showToast(`Invoice page ${page} uploaded${poNumber ? ` · PO ${poNumber}` : ''}`); + } catch (err) { + console.error('Invoice photo upload failed:', err); + const reason = err instanceof Error ? err.message : 'unknown error'; + showToast(`Invoice photo didn't upload (${reason}) — try again.`, 'error'); + throw err; } }; @@ -891,6 +922,7 @@ const loadItems = useCallback(async (showLoading = true) => { onTogglePO={isPoMode ? togglePO : undefined} onBulkCollect={isPoMode ? handleBulkCollect : undefined} onBulkProblem={isPoMode ? handleBulkProblem : undefined} + onAddInvoicePage={handleAddInvoicePage} /> ); }); @@ -906,6 +938,13 @@ const loadItems = useCallback(async (showLoading = true) => { isOpen={showCollectModal} onClose={() => { setShowCollectModal(false); setSelectedItem(null); }} onConfirm={handleCollect} + invoicePages={ + selectedItem?.expand?.purchase_order + ? countInvoicePages( + items.filter((i) => i.expand?.purchase_order?.id === selectedItem.expand?.purchase_order?.id), + ) + : 0 + } /> void; - onConfirm: (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => void; + onConfirm: (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => Promise | void; + /** Invoice pages already uploaded for this item's PO (from the PO header). */ + invoicePages?: number; } // Reject absurd files at the UI so the driver sees the constraint @@ -28,6 +30,7 @@ export default function CollectModal({ isOpen, onClose, onConfirm, + invoicePages = 0, }: CollectModalProps) { const [receivedQty, setReceivedQty] = useState(''); const [notes, setNotes] = useState(''); @@ -40,6 +43,11 @@ export default function CollectModal({ // no audit trail. Over-collections still pass through with just // notes (the driver may genuinely have nothing visual to add). const [partialReason, setPartialReason] = useState(''); + // True while the collection + photo upload is in flight. The button + // used to stay live: on slow mobile data the driver tapped again and + // every tap re-ran the save and re-uploaded the photo (one item ended + // up with 7 identical copies on 2026-10-16). + const [submitting, setSubmitting] = useState(false); const cameraInputRef = useRef(null); const libraryInputRef = useRef(null); @@ -75,8 +83,9 @@ export default function CollectModal({ setPhotoPreviewUrl(null); }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (submitting) return; setError(''); const qty = parseFloat(receivedQty); @@ -101,7 +110,12 @@ export default function CollectModal({ ? `[${partialReason}] ${notes}`.trim() : notes; - onConfirm(item, qty, finalNotes, photo); + setSubmitting(true); + try { + await onConfirm(item, qty, finalNotes, photo); + } finally { + setSubmitting(false); + } }; const adjustQty = (delta: number) => { @@ -290,6 +304,13 @@ export default function CollectModal({ Proof of pickup + {/* The invoice is photographed once per PO from the PO + header; this photo is for the item itself. */} +

+ {invoicePages > 0 + ? `Invoice already uploaded for this PO (${invoicePages} page${invoicePages === 1 ? '' : 's'}). Only add a photo here if something is wrong with this item.` + : 'Take the invoice photo once per PO with the Invoice button on the PO header.'} +

{photoPreviewUrl ? (
@@ -366,16 +387,24 @@ export default function CollectModal({ >
diff --git a/components/ItemDetailModal.tsx b/components/ItemDetailModal.tsx index a309472..9ae69fa 100644 --- a/components/ItemDetailModal.tsx +++ b/components/ItemDetailModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { OrderItem, PurchaseOrder } from '@/lib/types'; -import { pb } from '@/lib/pocketbase'; +import { buildPoFileUrl } from '@/lib/poDocs'; import { X, FileText, @@ -21,18 +21,6 @@ interface ItemDetailModalProps { onClose: () => void; } -// PocketBase's pb.files.getUrl expects a record object with id and -// collectionId. We satisfy the contract with a typed inline object rather -// than a broader `any` cast. -function buildPoFileUrl(po: PurchaseOrder): string | null { - if (!po.documents?.length) return null; - const record = { - id: po.id, - collectionId: (po as unknown as { collectionId: string }).collectionId ?? '', - }; - return pb.files.getUrl(record, po.documents[0]); -} - function statusColor(status: string): string { switch (status.toLowerCase()) { case 'sent': diff --git a/components/PoActions.tsx b/components/PoActions.tsx new file mode 100644 index 0000000..4ad605d --- /dev/null +++ b/components/PoActions.tsx @@ -0,0 +1,120 @@ +'use client'; + +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 { cn } from '@/lib/utils'; + +interface PoActionsProps { + purchaseOrder: PurchaseOrder | null; + items: OrderItem[]; + /** Uploads one invoice page for this PO. Rejects on failure. */ + onAddInvoicePage: (items: OrderItem[], file: File) => Promise; + /** False hides the photo button (e.g. the caller can't upload). */ + canPhoto?: boolean; + className?: string; +} + +// The two PO-level buttons on a PO header: open the PO PDF in one tap, +// and photograph the supplier invoice once for the whole PO (with +// "Page 2", "Page 3"… for multi-page invoices). +export default function PoActions({ + purchaseOrder, + items, + onAddInvoicePage, + canPhoto: photoAllowed = true, + className, +}: PoActionsProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const pdfUrl = buildPoFileUrl(purchaseOrder); + // Stock pickups have nowhere to store a photo, so a PO made only of + // pickups gets no photo button. + const canPhoto = photoAllowed && invoicePhotoTarget(items) !== null; + const pages = countInvoicePages(items); + + if (!pdfUrl && !canPhoto) return null; + + const handleFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Reset so taking the same shot again still fires onChange. + e.target.value = ''; + if (!file || uploading) return; + setUploading(true); + try { + await onAddInvoicePage(items, file); + } catch { + // The page handler already told the driver; nothing to add here. + } finally { + setUploading(false); + } + }; + + const poLabel = purchaseOrder?.po_number ? `PO ${purchaseOrder.po_number}` : 'this PO'; + + return ( +
+ {pdfUrl && ( + + + )} + {canPhoto && ( + <> + + + + )} +
+ ); +} diff --git a/components/SupplierGroup.tsx b/components/SupplierGroup.tsx index e067ebc..64c387b 100644 --- a/components/SupplierGroup.tsx +++ b/components/SupplierGroup.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { OrderItem, Supplier, isOutOfStock, problemBadgeLabel } from '@/lib/types'; import { cn, parseCoordinates } from '@/lib/utils'; import CollectionItemCard from '@/components/CollectionItemCard'; +import PoActions from '@/components/PoActions'; import { ChevronDown, ChevronUp, @@ -57,8 +58,13 @@ interface SupplierGroupProps { onTogglePO?: (poId: string) => void; onBulkCollect?: (poLabel: string, items: OrderItem[]) => void; onBulkProblem?: (poLabel: string, items: OrderItem[]) => void; + // Uploads one invoice photo page for a PO (see components/PoActions). + // Without it the PO headers only offer the PDF button. + onAddInvoicePage?: (items: OrderItem[], file: File) => Promise; } +const noUpload = async () => {}; + function getItemIcon(item: OrderItem) { if (item.problem_flag) return ; const orderNumber = item.expand?.order?.order_number || ''; @@ -316,10 +322,22 @@ export default function SupplierGroup({ onTogglePO, onBulkCollect, onBulkProblem, + onAddInvoicePage, }: SupplierGroupProps) { const coords = parseCoordinates(supplier.coordinates); const hasCoords = coords !== null; - const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0); + // In PO mode the page passes the items inside poGroups and leaves + // `items` empty, so count (and list, in detailed view) from both. + const allItems = poGroups ? poGroups.flatMap((po) => po.items) : items; + const totalQty = allItems.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0); + const showPoHeaders = Boolean(poGroups && onTogglePO) && viewMode === 'compact'; + // A supplier with a single PO gets the PO buttons once, at the top of + // its list, whenever there is no PO header to carry them. + const poIds = new Set(allItems.map((i) => i.expand?.purchase_order?.id ?? '')); + const singlePo = + !showPoHeaders && poIds.size === 1 && !poIds.has('') + ? allItems[0].expand?.purchase_order ?? null + : null; // Active tooltip: { itemId, type } const [activeTooltip, setActiveTooltip] = useState<{ itemId: string; type: 'po' | 'notes' } | null>(null); @@ -402,7 +420,7 @@ export default function SupplierGroup({
- {items.length} item{items.length !== 1 ? 's' : ''} + {allItems.length} item{allItems.length !== 1 ? 's' : ''} {totalQty} total
@@ -419,15 +437,15 @@ export default function SupplierGroup({ {/* Compact preview line (only when collapsed in compact mode) */} {viewMode === 'compact' && !isExpanded && (
- {items.slice(0, 3).map((item, i) => ( + {allItems.slice(0, 3).map((item, i) => ( {item.description || 'Item'} - {i < Math.min(items.length, 3) - 1 && ','} + {i < Math.min(allItems.length, 3) - 1 && ','} ))} - {items.length > 3 && ( + {allItems.length > 3 && ( - +{items.length - 3} more + +{allItems.length - 3} more )}
@@ -440,6 +458,20 @@ export default function SupplierGroup({ id={`supplier-${supplier.id}-items`} className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in" > + {singlePo && ( +
+
+ )} {viewMode === 'compact' ? ( /* COMPACT MODE — flat list when no PO grouping, otherwise nested PO sub-groups so the driver sees one invoice at a @@ -447,7 +479,7 @@ export default function SupplierGroup({ in both cases so the click-outside-tooltip handler still finds every row inside it. */
    - {poGroups && onTogglePO ? ( + {showPoHeaders && poGroups && onTogglePO ? ( poGroups.flatMap((po, poIdx) => { const poExpanded = expandedPOs?.has(po.poId) ?? true; const poTotalQty = po.items.reduce( @@ -481,6 +513,14 @@ export default function SupplierGroup({ · {po.items.length} {po.items.length === 1 ? 'item' : 'items'}{poTotalQty > 0 ? ` · ${poTotalQty} total` : ''} + {po.poId !== '__no_po__' && ( + + )} {/* Per-PO bulk actions — only rendered when the supplier actually has more than one PO (a single-PO supplier renders the flat list @@ -533,7 +573,7 @@ export default function SupplierGroup({ ]; }) ) : ( - items.map((item) => ( + allItems.map((item) => ( - {items.map((item) => ( + {allItems.map((item) => ( filename.toLowerCase().startsWith(INVOICE_PREFIX); + +// Collected lines drop off the driver's list, and with them any +// invoice page stored on them — the header would then ask for page 1 +// again. So the page count is also remembered per PO on the device. +const PAGES_KEY = 'wfdash-driver:invoice-pages'; + +function readRemembered(): Record { + try { + const raw = localStorage.getItem(PAGES_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function rememberPages(poId: string, pages: number) { + try { + const all = readRemembered(); + all[poId] = Math.max(all[poId] ?? 0, pages); + localStorage.setItem(PAGES_KEY, JSON.stringify(all)); + } catch { + // Private mode / storage full: the count just falls back to the list. + } +} + +/** Number of invoice photo pages already uploaded for the PO these lines belong to. */ +export function countInvoicePages(items: OrderItem[]): number { + const onList = items.reduce( + (n, item) => n + (item.order_item_attachements ?? []).filter(isInvoicePhoto).length, + 0, + ); + const poId = items[0]?.expand?.purchase_order?.id; + const remembered = poId ? readRemembered()[poId] ?? 0 : 0; + return Math.max(onList, remembered); +} + +/** + * The line the PO's invoice photo goes on. Stock pickups have no + * attachment field, so only customer order lines qualify. A line that + * already carries an invoice page wins, so page 2 lands next to page 1. + */ +export function invoicePhotoTarget(items: OrderItem[]): OrderItem | null { + const lines = items.filter((i) => i.kind !== 'pickup'); + return ( + lines.find((i) => (i.order_item_attachements ?? []).some(isInvoicePhoto)) ?? + lines[0] ?? + null + ); +} + +/** + * Compress and append one invoice page to `target`. Returns the row's + * full attachment list after the upload so the caller can refresh its + * local copy (and the page count) without a refetch. Throws on PB errors. + */ +export async function uploadInvoicePage(params: { + target: OrderItem; + poNumber: string; + page: number; + file: File; +}): Promise { + const { target, poNumber, page, file } = params; + const compressed = await compressPhoto(file); + const ext = compressed.type === 'image/jpeg' ? 'jpg' : (compressed.name.split('.').pop() || 'jpg'); + const safePo = poNumber.replace(/[^A-Za-z0-9]+/g, '') || 'po'; + const named = new File([compressed], `invoice-${safePo}-p${page}.${ext}`, { + type: compressed.type, + lastModified: Date.now(), + }); + + const formData = new FormData(); + formData.append('order_item_attachements+', named); + const updated = await pb.collection('order_items').update(target.id, formData); + const poId = target.expand?.purchase_order?.id; + if (poId) rememberPages(poId, page); + return (updated as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? []; +} diff --git a/lib/pocketbase.ts b/lib/pocketbase.ts index 52db308..1031cf9 100644 --- a/lib/pocketbase.ts +++ b/lib/pocketbase.ts @@ -739,7 +739,7 @@ export const bulkProblemStock = async ( // the raw camera file was what produced the run of `400 unexpected EOF` // failures on 2026-10-12: the multi-megabyte body got truncated in // transit on mobile data and PocketBase rejected the half-read -// multipart stream. Compressing to 1200px/q70 shrinks the same photo to +// multipart stream. Compressing (2000px/q80, see lib/image.ts) shrinks the photo to // a fraction of the size and the upload completes. // // The source/notes params are accepted for interface compatibility