WFDASHDRIVER-APP/lib/poDocs.ts
Dave de16082ff7 feat: one invoice photo per PO, one-tap PO PDF, save lock, sharper photos
- Confirm Collection locks while saving ("Saving…") and the page drops a
  second collect for an item already in flight. Repeat taps on slow data
  re-ran the save and re-uploaded the photo (7 identical copies on one
  item on 2026-10-16).
- PO headers (and a single-PO supplier's list) get an Invoice button:
  photograph the supplier invoice once per PO, then "add pg 2", "pg 3".
  Pages go onto one customer line of the PO as invoice-<PO>-p<N>.jpg;
  WFDASH Collection Confirm already gathers photos per PO card. The page
  count is also kept on the device so it survives lines dropping off.
- PO button opens the PO PDF in one tap (shared buildPoFileUrl in
  lib/poDocs.ts, also used by the item detail modal).
- Collect modal says whether the PO's invoice is already uploaded, so
  the per-item photo is only for problems with the item.
- Photos now upload at 2000px / q0.8 (was 1200px / q0.7) so small print
  on invoices stays readable for the AI checks.
- Supplier header counts and detailed view now include items in PO
  grouping mode (they showed 0 items before).

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

106 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 ?? [];
}