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:
parent
050b49e75c
commit
ebe88d40e7
5 changed files with 129 additions and 44 deletions
48
app/page.tsx
48
app/page.tsx
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
114
lib/poDocs.ts
114
lib/poDocs.ts
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue