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:
parent
286b86162b
commit
de16082ff7
8 changed files with 363 additions and 40 deletions
39
app/page.tsx
39
app/page.tsx
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
120
components/PoActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
19
lib/image.ts
19
lib/image.ts
|
|
@ -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
106
lib/poDocs.ts
Normal 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 ?? [];
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue