diff --git a/app/page.tsx b/app/page.tsx index 1f7b6e9..f261f69 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -30,7 +30,26 @@ import { bulkProblemStock, } from '@/lib/pocketbase'; import { getCached, setCached } from '@/lib/itemsCache'; -import { countInvoicePages, invoicePhotoTarget, uploadInvoicePage } from '@/lib/poDocs'; +import { + countInvoicePages, + invoicePhotoTarget, + uploadInvoicePage, + uploadStockPickupPhoto, + type PhotoTarget, +} from '@/lib/poDocs'; + +/** Items with the target's new file list applied (a line, or every pickup on a stock order). */ +function withTargetFiles(items: OrderItem[], target: PhotoTarget, files: string[]): OrderItem[] { + return items.map((i) => { + if (target.collection === 'order_items') { + return i.id === target.id ? { ...i, order_item_attachements: files } : i; + } + const po = i.expand?.purchase_order; + return i.kind === 'pickup' && po?.id === target.id + ? { ...i, expand: { ...i.expand, purchase_order: { ...po, additional_attachments: files } } } + : i; + }); +} import { cn, clusterByProximity } from '@/lib/utils'; import { RefreshCw, @@ -263,17 +282,30 @@ const loadItems = useCallback(async (showLoading = true) => { const expectedQty = getItemDisplayQty(item); if (item.kind === 'pickup') { - // Stock-pickup stop — PATCH stock_purchase_items. No photo - // upload on this side (stock_purchase_items has no - // attachments collection wired in PB). + // Stock-pickup stop — PATCH stock_purchase_items. The photo goes + // on the stock order (stock_purchases.additional_attachments): + // the lines have no file field. It used to be dropped. await updateStockCollectionStatus(item.id, { qty_collected: receivedQty, notes, expectedQty, }); + let stockPhotoError = ''; + if (photo) { + try { + await uploadStockPickupPhoto(item, photo); + } catch (uploadErr) { + stockPhotoError = uploadErr instanceof Error ? uploadErr.message : 'unknown error'; + console.error('Stock pickup photo upload failed:', uploadErr); + } + } setItems((prev) => prev.filter((i) => i.id !== item.id)); setShowCollectModal(false); - showToast('Stock pickup collected successfully'); + if (stockPhotoError) { + showToast(`Collected, but the photo didn't upload (${stockPhotoError}) — add it from the office.`, 'warning'); + } else { + showToast(photo ? 'Stock pickup collected · photo uploaded' : 'Stock pickup collected successfully'); + } return; } @@ -354,13 +386,11 @@ const loadItems = useCallback(async (showLoading = true) => { const handleAddInvoicePage = async (poItems: OrderItem[], file: File) => { const target = invoicePhotoTarget(poItems); if (!target) return; - const poNumber = target.expand?.purchase_order?.po_number ?? ''; + const poNumber = poItems[0]?.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)), - ); + setItems((prev) => withTargetFiles(prev, target, files)); showToast(`Invoice page ${page} uploaded${poNumber ? ` · PO ${poNumber}` : ''}`); } catch (err) { console.error('Invoice photo upload failed:', err); diff --git a/components/PoActions.tsx b/components/PoActions.tsx index a1423c8..4d99643 100644 --- a/components/PoActions.tsx +++ b/components/PoActions.tsx @@ -33,8 +33,8 @@ export default function PoActions({ 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. + // A customer PO's photo goes on one of its lines; a stock order's on + // the stock purchase. No target (no PO at all) means no photo button. const canPhoto = photoAllowed && invoicePhotoTarget(items) !== null; const pages = countInvoicePages(items); diff --git a/lib/poDocs.ts b/lib/poDocs.ts index cb07df7..428d20a 100644 --- a/lib/poDocs.ts +++ b/lib/poDocs.ts @@ -52,55 +52,103 @@ function rememberPages(poId: string, pages: number) { } } +/** + * Where a PO's photos go. Customer POs: one of the order lines + * (`order_item_attachements`); WFDASH Collection Confirm gathers photos + * from every line of the card. Stock orders (pickups): the stock + * purchase itself (`additional_attachments`) — the lines have no file + * field. Before 2026-10-19 stock pickup photos were dropped. + */ +export type PhotoTarget = { + collection: 'order_items' | 'stock_purchases'; + field: 'order_item_attachements' | 'additional_attachments'; + id: string; + /** The PO these photos belong to (for the remembered page count). */ + poId: string; + /** Files already on the target. */ + files: string[]; +}; + +/** + * The record the PO's invoice photo goes on. For a customer PO, a line + * that already carries an invoice page wins, so page 2 lands next to + * page 1. For a stock order, the stock purchase. + */ +export function invoicePhotoTarget(items: OrderItem[]): PhotoTarget | null { + const lines = items.filter((i) => i.kind !== 'pickup'); + if (lines.length > 0) { + const line = lines.find((i) => (i.order_item_attachements ?? []).some(isInvoicePhoto)) ?? lines[0]; + return { + collection: 'order_items', + field: 'order_item_attachements', + id: line.id, + poId: line.expand?.purchase_order?.id ?? '', + files: line.order_item_attachements ?? [], + }; + } + return stockPhotoTarget(items[0]); +} + +/** The stock purchase a pickup line belongs to, as a photo target. */ +export function stockPhotoTarget(item: OrderItem | undefined): PhotoTarget | null { + const po = item?.kind === 'pickup' ? item.expand?.purchase_order : undefined; + if (!po?.id) return null; + return { + collection: 'stock_purchases', + field: 'additional_attachments', + id: po.id, + poId: po.id, + files: po.additional_attachments ?? [], + }; +} + /** 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 stock = items.length > 0 && items.every((i) => i.kind === 'pickup'); + const onList = stock + ? (items[0]?.expand?.purchase_order?.additional_attachments ?? []).filter(isInvoicePhoto).length + : 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 ADD `file` (renamed `stem.` if given) to the target; returns its files afterwards. */ +async function addPhoto(target: PhotoTarget, file: File, stem?: string): Promise { + const compressed = await compressPhoto(file); + const ext = compressed.type === 'image/jpeg' ? 'jpg' : compressed.name.split('.').pop() || 'jpg'; + const named = stem + ? new File([compressed], `${stem}.${ext}`, { type: compressed.type, lastModified: Date.now() }) + : compressed; + const formData = new FormData(); + // "+" adds; the bare field name would replace every file already there. + formData.append(`${target.field}+`, named); + const updated = await pb.collection(target.collection).update(target.id, formData); + const raw = (updated as unknown as Record)[target.field]; + return Array.isArray(raw) ? (raw as string[]) : typeof raw === 'string' && raw ? [raw] : []; } /** - * 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. + * Upload one invoice page for a PO. Returns the target's files 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; + target: PhotoTarget; 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 ?? []; + const files = await addPhoto(target, file, `invoice-${safePo}-p${page}`); + if (target.poId) rememberPages(target.poId, page); + return files; +} + +/** Keep the photo a driver took while collecting a stock pickup (on its stock order). */ +export async function uploadStockPickupPhoto(item: OrderItem, file: File): Promise { + const target = stockPhotoTarget(item); + if (!target) throw new Error('stock order not found for this pickup'); + return addPhoto(target, file); } diff --git a/lib/pocketbase.ts b/lib/pocketbase.ts index 1031cf9..e6a8873 100644 --- a/lib/pocketbase.ts +++ b/lib/pocketbase.ts @@ -334,6 +334,10 @@ const toOrderItemFromStock = (stock: StockPurchaseItem): OrderItem => { id: purchase.id, po_number: purchase.purchase_number || '', status: purchase.status, + additional_attachments: (() => { + const raw = (purchase as { additional_attachments?: unknown }).additional_attachments; + return Array.isArray(raw) ? raw : typeof raw === 'string' && raw ? [raw] : []; + })(), expand: { supplier: supplierFromPurchase }, supplier: purchase.supplier, } as unknown as PurchaseOrder) diff --git a/lib/types.ts b/lib/types.ts index 50f066a..ff9c9d9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -22,6 +22,9 @@ export interface PurchaseOrder { status: string; // Attached PO PDF documents (file field on the purchase_orders PB collection) documents?: string[]; + // Stock orders only: photos on the stock purchase (driver's invoice + // photos, operator uploads). Multi-file since pb:migrate:stock-photos. + additional_attachments?: string[]; // Pre-composed email payload (subject, body, to) — driver can resend // this to the supplier via the "Email PO" button when the supplier // hasn't received it.