WFDASHDRIVER-APP/lib/poDocs.ts
Dave ebe88d40e7 feat: keep stock pickup photos on the stock order
Stock pickup photos were dropped: the Collect window ignored the photo
for a pickup, and the PO header's Invoice button didn't show for stock
orders (stock_purchase_items has no file field).

- Photos for a stock order go on the stock purchase
  (stock_purchases.additional_attachments, multi-file after WFDASH's
  `npm run pb:migrate:stock-photos`), always added with `field+`.
- Invoice button works on stock orders (pages counted from the stock
  purchase's invoice_ files); the Collect window uploads a pickup's photo
  there too, and warns if it didn't upload.
- poDocs: one PhotoTarget (order line or stock purchase) behind
  invoicePhotoTarget / uploadInvoicePage / uploadStockPickupPhoto.

Run the WFDASH stock-photos migration before deploying this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:02:06 +02:00

154 lines
6.2 KiB
TypeScript

// 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<string, number> {
try {
const raw = localStorage.getItem(PAGES_KEY);
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
} 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.<ext>` if given) to the target; returns its files afterwards. */
async function addPhoto(target: PhotoTarget, file: File, stem?: string): Promise<string[]> {
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<string, unknown>)[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<string[]> {
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<string[]> {
const target = stockPhotoTarget(item);
if (!target) throw new Error('stock order not found for this pickup');
return addPhoto(target, file);
}