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>
154 lines
6.2 KiB
TypeScript
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);
|
|
}
|