107 lines
4.1 KiB
TypeScript
107 lines
4.1 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.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 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<string[]> {
|
||
|
|
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 ?? [];
|
||
|
|
}
|