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>
This commit is contained in:
Dave 2026-10-19 17:02:06 +02:00
parent 050b49e75c
commit ebe88d40e7
5 changed files with 129 additions and 44 deletions

View file

@ -30,7 +30,26 @@ import {
bulkProblemStock,
} from '@/lib/pocketbase';
import { getCached, setCached } from '@/lib/itemsCache';
import { countInvoicePages, invoicePhotoTarget, uploadInvoicePage } from '@/lib/poDocs';
import {
countInvoicePages,
invoicePhotoTarget,
uploadInvoicePage,
uploadStockPickupPhoto,
type PhotoTarget,
} from '@/lib/poDocs';
/** Items with the target's new file list applied (a line, or every pickup on a stock order). */
function withTargetFiles(items: OrderItem[], target: PhotoTarget, files: string[]): OrderItem[] {
return items.map((i) => {
if (target.collection === 'order_items') {
return i.id === target.id ? { ...i, order_item_attachements: files } : i;
}
const po = i.expand?.purchase_order;
return i.kind === 'pickup' && po?.id === target.id
? { ...i, expand: { ...i.expand, purchase_order: { ...po, additional_attachments: files } } }
: i;
});
}
import { cn, clusterByProximity } from '@/lib/utils';
import {
RefreshCw,
@ -263,17 +282,30 @@ const loadItems = useCallback(async (showLoading = true) => {
const expectedQty = getItemDisplayQty(item);
if (item.kind === 'pickup') {
// Stock-pickup stop — PATCH stock_purchase_items. No photo
// upload on this side (stock_purchase_items has no
// attachments collection wired in PB).
// Stock-pickup stop — PATCH stock_purchase_items. The photo goes
// on the stock order (stock_purchases.additional_attachments):
// the lines have no file field. It used to be dropped.
await updateStockCollectionStatus(item.id, {
qty_collected: receivedQty,
notes,
expectedQty,
});
let stockPhotoError = '';
if (photo) {
try {
await uploadStockPickupPhoto(item, photo);
} catch (uploadErr) {
stockPhotoError = uploadErr instanceof Error ? uploadErr.message : 'unknown error';
console.error('Stock pickup photo upload failed:', uploadErr);
}
}
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowCollectModal(false);
showToast('Stock pickup collected successfully');
if (stockPhotoError) {
showToast(`Collected, but the photo didn't upload (${stockPhotoError}) — add it from the office.`, 'warning');
} else {
showToast(photo ? 'Stock pickup collected · photo uploaded' : 'Stock pickup collected successfully');
}
return;
}
@ -354,13 +386,11 @@ const loadItems = useCallback(async (showLoading = true) => {
const handleAddInvoicePage = async (poItems: OrderItem[], file: File) => {
const target = invoicePhotoTarget(poItems);
if (!target) return;
const poNumber = target.expand?.purchase_order?.po_number ?? '';
const poNumber = poItems[0]?.expand?.purchase_order?.po_number ?? '';
const page = countInvoicePages(poItems) + 1;
try {
const files = await uploadInvoicePage({ target, poNumber, page, file });
setItems((prev) =>
prev.map((i) => (i.id === target.id ? { ...i, order_item_attachements: files } : i)),
);
setItems((prev) => withTargetFiles(prev, target, files));
showToast(`Invoice page ${page} uploaded${poNumber ? ` · PO ${poNumber}` : ''}`);
} catch (err) {
console.error('Invoice photo upload failed:', err);

View file

@ -33,8 +33,8 @@ export default function PoActions({
const [uploading, setUploading] = useState(false);
const pdfUrl = buildPoFileUrl(purchaseOrder);
// Stock pickups have nowhere to store a photo, so a PO made only of
// pickups gets no photo button.
// A customer PO's photo goes on one of its lines; a stock order's on
// the stock purchase. No target (no PO at all) means no photo button.
const canPhoto = photoAllowed && invoicePhotoTarget(items) !== null;
const pages = countInvoicePages(items);

View file

@ -52,55 +52,103 @@ function rememberPages(poId: string, pages: number) {
}
}
/**
* 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 onList = items.reduce(
(n, item) => n + (item.order_item_attachements ?? []).filter(isInvoicePhoto).length,
0,
);
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);
}
/**
* 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 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] : [];
}
/**
* 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.
* 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: OrderItem;
target: PhotoTarget;
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 ?? [];
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);
}

View file

@ -334,6 +334,10 @@ const toOrderItemFromStock = (stock: StockPurchaseItem): OrderItem => {
id: purchase.id,
po_number: purchase.purchase_number || '',
status: purchase.status,
additional_attachments: (() => {
const raw = (purchase as { additional_attachments?: unknown }).additional_attachments;
return Array.isArray(raw) ? raw : typeof raw === 'string' && raw ? [raw] : [];
})(),
expand: { supplier: supplierFromPurchase },
supplier: purchase.supplier,
} as unknown as PurchaseOrder)

View file

@ -22,6 +22,9 @@ export interface PurchaseOrder {
status: string;
// Attached PO PDF documents (file field on the purchase_orders PB collection)
documents?: string[];
// Stock orders only: photos on the stock purchase (driver's invoice
// photos, operator uploads). Multi-file since pb:migrate:stock-photos.
additional_attachments?: string[];
// Pre-composed email payload (subject, body, to) — driver can resend
// this to the supplier via the "Email PO" button when the supplier
// hasn't received it.