// Purchase-order level documents for the driver: the PO PDF the office // attached, and the supplier invoice photo the driver takes once per PO. // // Why per PO: drivers used to photograph the same invoice on every // line they collected (one item ended up with 7 identical copies on // 2026-10-16). WFDASH's Collection Confirm already reads photos per PO // card — it gathers every collected line's attachments and dedupes — // so one upload onto one line of the PO is all it needs. import { pb } from './pocketbase'; import { compressPhoto } from './image'; import { OrderItem, PurchaseOrder } from './types'; /** URL of the first PDF on the purchase order, or null when the office attached none. */ export function buildPoFileUrl(po: PurchaseOrder | null | undefined): 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]); } // PocketBase snake-cases the uploaded name and adds a random suffix, // so "invoice-PO123-p1.jpg" is stored as "invoice_po123_p1_ab12cd34ef.jpg". // The prefix is what lets us count pages after a reload. const INVOICE_PREFIX = 'invoice_'; const isInvoicePhoto = (filename: string) => 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. } } /** * 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 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); } /** 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] : []; } /** * 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: PhotoTarget; poNumber: string; page: number; file: File; }): Promise { const { target, poNumber, page, file } = params; const safePo = poNumber.replace(/[^A-Za-z0-9]+/g, '') || 'po'; 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); }