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>
This commit is contained in:
Dave 2026-10-17 17:43:35 +02:00
parent 286b86162b
commit de16082ff7
8 changed files with 363 additions and 40 deletions

View file

@ -30,6 +30,7 @@ import {
bulkProblemStock,
} from '@/lib/pocketbase';
import { getCached, setCached } from '@/lib/itemsCache';
import { countInvoicePages, invoicePhotoTarget, uploadInvoicePage } from '@/lib/poDocs';
import { cn, clusterByProximity } from '@/lib/utils';
import {
RefreshCw,
@ -246,7 +247,14 @@ const loadItems = useCallback(async (showLoading = true) => {
setTimeout(() => setToast(null), 3000);
};
// Item ids whose collect is still in flight. The modal disables its
// button while saving, but this is the backstop: a second call for
// the same item is dropped instead of re-saving and re-uploading.
const collectingRef = useRef<Set<string>>(new Set());
const handleCollect = async (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => {
if (collectingRef.current.has(item.id)) return;
collectingRef.current.add(item.id);
try {
// expectedQty is computed from the same getItemDisplayQty the
// modal uses, so the comparison happens against the same number
@ -335,6 +343,29 @@ const loadItems = useCallback(async (showLoading = true) => {
}
} catch (err) {
showToast('Failed to update collection', 'error');
} finally {
collectingRef.current.delete(item.id);
}
};
// One invoice photo per PO, taken from the PO header. Page N goes on
// the same line as page 1 so the office sees the pages together.
const handleAddInvoicePage = async (poItems: OrderItem[], file: File) => {
const target = invoicePhotoTarget(poItems);
if (!target) return;
const poNumber = target.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)),
);
showToast(`Invoice page ${page} uploaded${poNumber ? ` · PO ${poNumber}` : ''}`);
} catch (err) {
console.error('Invoice photo upload failed:', err);
const reason = err instanceof Error ? err.message : 'unknown error';
showToast(`Invoice photo didn't upload (${reason}) — try again.`, 'error');
throw err;
}
};
@ -891,6 +922,7 @@ const loadItems = useCallback(async (showLoading = true) => {
onTogglePO={isPoMode ? togglePO : undefined}
onBulkCollect={isPoMode ? handleBulkCollect : undefined}
onBulkProblem={isPoMode ? handleBulkProblem : undefined}
onAddInvoicePage={handleAddInvoicePage}
/>
);
});
@ -906,6 +938,13 @@ const loadItems = useCallback(async (showLoading = true) => {
isOpen={showCollectModal}
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
onConfirm={handleCollect}
invoicePages={
selectedItem?.expand?.purchase_order
? countInvoicePages(
items.filter((i) => i.expand?.purchase_order?.id === selectedItem.expand?.purchase_order?.id),
)
: 0
}
/>
<ProblemModal
item={selectedItem}

View file

@ -4,13 +4,15 @@ import { useState, useRef } from 'react';
import { OrderItem } from '@/lib/types';
import { cn, formatCurrency } from '@/lib/utils';
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
import { X, Minus, Plus, Check, Camera, ImagePlus, Trash2, AlertCircle } from 'lucide-react';
import { X, Minus, Plus, Check, Camera, ImagePlus, Trash2, AlertCircle, Loader2 } from 'lucide-react';
interface CollectModalProps {
item: OrderItem | null;
isOpen: boolean;
onClose: () => void;
onConfirm: (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => void;
onConfirm: (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => Promise<void> | void;
/** Invoice pages already uploaded for this item's PO (from the PO header). */
invoicePages?: number;
}
// Reject absurd files at the UI so the driver sees the constraint
@ -28,6 +30,7 @@ export default function CollectModal({
isOpen,
onClose,
onConfirm,
invoicePages = 0,
}: CollectModalProps) {
const [receivedQty, setReceivedQty] = useState('');
const [notes, setNotes] = useState('');
@ -40,6 +43,11 @@ export default function CollectModal({
// no audit trail. Over-collections still pass through with just
// notes (the driver may genuinely have nothing visual to add).
const [partialReason, setPartialReason] = useState('');
// True while the collection + photo upload is in flight. The button
// used to stay live: on slow mobile data the driver tapped again and
// every tap re-ran the save and re-uploaded the photo (one item ended
// up with 7 identical copies on 2026-10-16).
const [submitting, setSubmitting] = useState(false);
const cameraInputRef = useRef<HTMLInputElement>(null);
const libraryInputRef = useRef<HTMLInputElement>(null);
@ -75,8 +83,9 @@ export default function CollectModal({
setPhotoPreviewUrl(null);
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
setError('');
const qty = parseFloat(receivedQty);
@ -101,7 +110,12 @@ export default function CollectModal({
? `[${partialReason}] ${notes}`.trim()
: notes;
onConfirm(item, qty, finalNotes, photo);
setSubmitting(true);
try {
await onConfirm(item, qty, finalNotes, photo);
} finally {
setSubmitting(false);
}
};
const adjustQty = (delta: number) => {
@ -290,6 +304,13 @@ export default function CollectModal({
Proof of pickup
</span>
</div>
{/* The invoice is photographed once per PO from the PO
header; this photo is for the item itself. */}
<p className="text-[12px] text-[rgba(38,37,30,0.6)] mb-2">
{invoicePages > 0
? `Invoice already uploaded for this PO (${invoicePages} page${invoicePages === 1 ? '' : 's'}). Only add a photo here if something is wrong with this item.`
: 'Take the invoice photo once per PO with the Invoice button on the PO header.'}
</p>
{photoPreviewUrl ? (
<div className="relative rounded-xl overflow-hidden border border-[rgba(38,37,30,0.1)] bg-[#f2f1ed]">
@ -366,16 +387,24 @@ export default function CollectModal({
>
<button
type="submit"
disabled={!parsedQty || parsedQty < 0 || (isPartial && !partialJustified)}
disabled={submitting || !parsedQty || parsedQty < 0 || (isPartial && !partialJustified)}
aria-busy={submitting}
className={cn(
'w-full py-4 px-4 rounded-xl text-white font-semibold text-[15px]',
'bg-[#1f8a65] active:bg-[#125a3e] transition-all',
'touch-manipulation press-feedback min-h-[56px]',
(!parsedQty || parsedQty < 0 || (isPartial && !partialJustified)) && 'opacity-50'
'touch-manipulation press-feedback min-h-[56px] flex items-center justify-center gap-2',
(submitting || !parsedQty || parsedQty < 0 || (isPartial && !partialJustified)) && 'opacity-50'
)}
style={{ boxShadow: '0 4px 12px rgba(31,138,101,0.3)' }}
>
Confirm Collection
{submitting ? (
<>
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
{photo ? 'Saving & uploading photo…' : 'Saving…'}
</>
) : (
'Confirm Collection'
)}
</button>
</div>
</form>

View file

@ -1,7 +1,7 @@
'use client';
import { OrderItem, PurchaseOrder } from '@/lib/types';
import { pb } from '@/lib/pocketbase';
import { buildPoFileUrl } from '@/lib/poDocs';
import {
X,
FileText,
@ -21,18 +21,6 @@ interface ItemDetailModalProps {
onClose: () => void;
}
// PocketBase's pb.files.getUrl expects a record object with id and
// collectionId. We satisfy the contract with a typed inline object rather
// than a broader `any` cast.
function buildPoFileUrl(po: PurchaseOrder): 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]);
}
function statusColor(status: string): string {
switch (status.toLowerCase()) {
case 'sent':

120
components/PoActions.tsx Normal file
View file

@ -0,0 +1,120 @@
'use client';
import { useRef, useState } from 'react';
import { Camera, FileText, Loader2, Check } from 'lucide-react';
import { OrderItem, PurchaseOrder } from '@/lib/types';
import { buildPoFileUrl, countInvoicePages, invoicePhotoTarget } from '@/lib/poDocs';
import { cn } from '@/lib/utils';
interface PoActionsProps {
purchaseOrder: PurchaseOrder | null;
items: OrderItem[];
/** Uploads one invoice page for this PO. Rejects on failure. */
onAddInvoicePage: (items: OrderItem[], file: File) => Promise<void>;
/** False hides the photo button (e.g. the caller can't upload). */
canPhoto?: boolean;
className?: string;
}
// The two PO-level buttons on a PO header: open the PO PDF in one tap,
// and photograph the supplier invoice once for the whole PO (with
// "Page 2", "Page 3"… for multi-page invoices).
export default function PoActions({
purchaseOrder,
items,
onAddInvoicePage,
canPhoto: photoAllowed = true,
className,
}: PoActionsProps) {
const inputRef = useRef<HTMLInputElement>(null);
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.
const canPhoto = photoAllowed && invoicePhotoTarget(items) !== null;
const pages = countInvoicePages(items);
if (!pdfUrl && !canPhoto) return null;
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset so taking the same shot again still fires onChange.
e.target.value = '';
if (!file || uploading) return;
setUploading(true);
try {
await onAddInvoicePage(items, file);
} catch {
// The page handler already told the driver; nothing to add here.
} finally {
setUploading(false);
}
};
const poLabel = purchaseOrder?.po_number ? `PO ${purchaseOrder.po_number}` : 'this PO';
return (
<div className={cn('flex items-center gap-1 flex-shrink-0', className)}>
{pdfUrl && (
<a
href={pdfUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open the PDF for ${poLabel}`}
className="h-7 px-2 rounded-md bg-[#efeaf7] text-[#6c5a99] text-[11px] font-semibold flex items-center gap-1 active:bg-[#ded5ef] transition-colors touch-manipulation"
>
<FileText size={11} aria-hidden="true" />
<span>PO</span>
</a>
)}
{canPhoto && (
<>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
aria-busy={uploading}
aria-label={
pages === 0
? `Take a photo of the invoice for ${poLabel}`
: `Add page ${pages + 1} of the invoice for ${poLabel}`
}
className={cn(
'h-7 px-2 rounded-md text-[11px] font-semibold flex items-center gap-1 transition-colors touch-manipulation',
pages === 0
? 'bg-[#26251e] text-white active:bg-black'
: 'bg-[#e6f1ec] text-[#125a3e] active:bg-[#cfe6db]',
uploading && 'opacity-60',
)}
>
{uploading ? (
<Loader2 size={11} className="animate-spin" aria-hidden="true" />
) : pages === 0 ? (
<Camera size={11} aria-hidden="true" />
) : (
<Check size={11} aria-hidden="true" />
)}
<span>
{uploading
? 'Uploading…'
: pages === 0
? 'Invoice'
: `${pages} pg · add pg ${pages + 1}`}
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleFile}
className="hidden"
aria-hidden="true"
tabIndex={-1}
/>
</>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { OrderItem, Supplier, isOutOfStock, problemBadgeLabel } from '@/lib/types';
import { cn, parseCoordinates } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
import PoActions from '@/components/PoActions';
import {
ChevronDown,
ChevronUp,
@ -57,8 +58,13 @@ interface SupplierGroupProps {
onTogglePO?: (poId: string) => void;
onBulkCollect?: (poLabel: string, items: OrderItem[]) => void;
onBulkProblem?: (poLabel: string, items: OrderItem[]) => void;
// Uploads one invoice photo page for a PO (see components/PoActions).
// Without it the PO headers only offer the PDF button.
onAddInvoicePage?: (items: OrderItem[], file: File) => Promise<void>;
}
const noUpload = async () => {};
function getItemIcon(item: OrderItem) {
if (item.problem_flag) return <AlertTriangle size={12} className="text-red-500" />;
const orderNumber = item.expand?.order?.order_number || '';
@ -316,10 +322,22 @@ export default function SupplierGroup({
onTogglePO,
onBulkCollect,
onBulkProblem,
onAddInvoicePage,
}: SupplierGroupProps) {
const coords = parseCoordinates(supplier.coordinates);
const hasCoords = coords !== null;
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
// In PO mode the page passes the items inside poGroups and leaves
// `items` empty, so count (and list, in detailed view) from both.
const allItems = poGroups ? poGroups.flatMap((po) => po.items) : items;
const totalQty = allItems.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
const showPoHeaders = Boolean(poGroups && onTogglePO) && viewMode === 'compact';
// A supplier with a single PO gets the PO buttons once, at the top of
// its list, whenever there is no PO header to carry them.
const poIds = new Set(allItems.map((i) => i.expand?.purchase_order?.id ?? ''));
const singlePo =
!showPoHeaders && poIds.size === 1 && !poIds.has('')
? allItems[0].expand?.purchase_order ?? null
: null;
// Active tooltip: { itemId, type }
const [activeTooltip, setActiveTooltip] = useState<{ itemId: string; type: 'po' | 'notes' } | null>(null);
@ -402,7 +420,7 @@ export default function SupplierGroup({
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-col items-end gap-0.5">
<span className="text-[12px] font-bold text-[#26251e]">
{items.length} item{items.length !== 1 ? 's' : ''}
{allItems.length} item{allItems.length !== 1 ? 's' : ''}
</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">{totalQty} total</span>
</div>
@ -419,15 +437,15 @@ export default function SupplierGroup({
{/* Compact preview line (only when collapsed in compact mode) */}
{viewMode === 'compact' && !isExpanded && (
<div className="mt-2 ml-12 flex items-center gap-1.5 flex-wrap">
{items.slice(0, 3).map((item, i) => (
{allItems.slice(0, 3).map((item, i) => (
<span key={item.id} className="text-[11px] text-[rgba(38,37,30,0.6)] truncate max-w-[140px]">
{item.description || 'Item'}
{i < Math.min(items.length, 3) - 1 && ','}
{i < Math.min(allItems.length, 3) - 1 && ','}
</span>
))}
{items.length > 3 && (
{allItems.length > 3 && (
<span className="text-[10px] text-[rgba(38,37,30,0.4)] font-medium">
+{items.length - 3} more
+{allItems.length - 3} more
</span>
)}
</div>
@ -440,6 +458,20 @@ export default function SupplierGroup({
id={`supplier-${supplier.id}-items`}
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
>
{singlePo && (
<div className="flex items-center gap-2 px-4 py-2 bg-[#ebeae5] border-b border-[rgba(38,37,30,0.06)]">
<FileText size={12} className="text-[#6c5a99] flex-shrink-0" aria-hidden="true" />
<span className="text-[12px] font-semibold text-[#26251e] font-mono truncate flex-1 min-w-0">
{singlePo.po_number}
</span>
<PoActions
purchaseOrder={singlePo}
items={allItems}
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
</div>
)}
{viewMode === 'compact' ? (
/* COMPACT MODE — flat list when no PO grouping, otherwise
nested PO sub-groups so the driver sees one invoice at a
@ -447,7 +479,7 @@ export default function SupplierGroup({
in both cases so the click-outside-tooltip handler still
finds every row inside it. */
<ul ref={listRef} className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
{poGroups && onTogglePO ? (
{showPoHeaders && poGroups && onTogglePO ? (
poGroups.flatMap((po, poIdx) => {
const poExpanded = expandedPOs?.has(po.poId) ?? true;
const poTotalQty = po.items.reduce(
@ -481,6 +513,14 @@ export default function SupplierGroup({
· {po.items.length} {po.items.length === 1 ? 'item' : 'items'}{poTotalQty > 0 ? ` · ${poTotalQty} total` : ''}
</span>
</button>
{po.poId !== '__no_po__' && (
<PoActions
purchaseOrder={po.items[0]?.expand?.purchase_order ?? null}
items={po.items}
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
)}
{/* Per-PO bulk actions — only rendered when the
supplier actually has more than one PO (a
single-PO supplier renders the flat list
@ -533,7 +573,7 @@ export default function SupplierGroup({
];
})
) : (
items.map((item) => (
allItems.map((item) => (
<CompactItemRow
key={item.id}
item={item}
@ -551,7 +591,7 @@ export default function SupplierGroup({
) : (
/* DETAILED MODE: full item cards */
<div className="p-3 space-y-2">
{items.map((item) => (
{allItems.map((item) => (
<CollectionItemCard
key={item.id}
item={item}

View file

@ -12,18 +12,19 @@
* `order_item_attachements` has `mimeTypes=null`, so nothing was
* rejecting the format.
*
* The office side never hit this because `attachment-compressor.ts`
* in WFDASH re-encodes to 1200px / quality 70 before storing. These
* constants deliberately mirror `JPG_MAX_WIDTH` and `JPG_QUALITY`
* there so both sides produce comparable images. A 1200px q70 JPEG
* lands around 150–300 KB — a sub-second transfer that finishes.
* Size choice (2026-10-17): these photos are mostly supplier invoices
* that the WFDASH AI reads on Collection Confirm. At 1200px / q70 a
* landscape invoice shot sideways left stock codes too blurry to read
* (the AI turned "051315" into "0515315"). 2000px / q80 keeps small
* print legible and still lands around 400–600 KB — an order of
* magnitude below the multi-MB camera files that failed in transit.
*/
/** Longest edge, in pixels, of the uploaded image. Mirrors WFDASH's JPG_MAX_WIDTH. */
export const MAX_PHOTO_EDGE = 1200;
/** Longest edge, in pixels, of the uploaded image. */
export const MAX_PHOTO_EDGE = 2000;
/** JPEG encoder quality, 0–1. Mirrors WFDASH's JPG_QUALITY (70). */
export const PHOTO_QUALITY = 0.7;
/** JPEG encoder quality, 0–1. */
export const PHOTO_QUALITY = 0.8;
/**
* Below this, the file is already small enough that re-encoding costs

106
lib/poDocs.ts Normal file
View file

@ -0,0 +1,106 @@
// 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 ?? [];
}

View file

@ -739,7 +739,7 @@ export const bulkProblemStock = async (
// the raw camera file was what produced the run of `400 unexpected EOF`
// failures on 2026-10-12: the multi-megabyte body got truncated in
// transit on mobile data and PocketBase rejected the half-read
// multipart stream. Compressing to 1200px/q70 shrinks the same photo to
// multipart stream. Compressing (2000px/q80, see lib/image.ts) shrinks the photo to
// a fraction of the size and the upload completes.
//
// The source/notes params are accepted for interface compatibility