WFDASHDRIVER-APP/components/CollectModal.tsx
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

414 lines
No EOL
19 KiB
TypeScript

'use client';
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, Loader2 } from 'lucide-react';
interface CollectModalProps {
item: OrderItem | null;
isOpen: boolean;
onClose: () => 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
// immediately rather than after a long stalled upload. This is NOT the
// size that actually goes over the wire — `compressPhoto` (lib/image.ts)
// re-encodes to 1200px/q70 before upload, which lands in the low
// hundreds of KB. Raw camera files were multi-megabyte and every one of
// them was truncated in transit on 2026-10-12; the cap here just stops
// someone picking a 50MB file off a camera roll.
const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
const ALLOWED_PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
export default function CollectModal({
item,
isOpen,
onClose,
onConfirm,
invoicePages = 0,
}: CollectModalProps) {
const [receivedQty, setReceivedQty] = useState('');
const [notes, setNotes] = useState('');
const [error, setError] = useState('');
const [photo, setPhoto] = useState<File | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string | null>(null);
// Reason the driver is collecting less than the expected qty. We
// require *something* on a partial — either a picked reason or a
// photo — so a partial collection can't be silently submitted with
// 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);
if (!isOpen || !item) return null;
const expectedQty = getItemDisplayQty(item);
const parsedQty = parseFloat(receivedQty) || 0;
const isPartial = parsedQty > 0 && parsedQty < expectedQty;
const isOverQty = parsedQty > expectedQty;
// True once the driver has justified the partial one way or another.
const partialJustified = partialReason.trim() !== '' || photo !== null;
const displayDescription = getItemDisplayDescription(item);
const handlePhotoSelected = (file: File | undefined | null) => {
if (!file) return;
if (!ALLOWED_PHOTO_TYPES.includes(file.type)) {
setError(`Photo must be JPEG, PNG, WebP, or HEIC (got ${file.type || 'unknown'})`);
return;
}
if (file.size > MAX_PHOTO_BYTES) {
setError(`Photo is too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Max is 10MB.`);
return;
}
setError('');
if (photoPreviewUrl) URL.revokeObjectURL(photoPreviewUrl);
setPhotoPreviewUrl(URL.createObjectURL(file));
setPhoto(file);
};
const handleRemovePhoto = () => {
if (photoPreviewUrl) URL.revokeObjectURL(photoPreviewUrl);
setPhoto(null);
setPhotoPreviewUrl(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
setError('');
const qty = parseFloat(receivedQty);
if (isNaN(qty) || qty <= 0) {
setError('Please enter a valid quantity');
return;
}
// Partial collections must be justified — either a picked reason
// or a photo. Without one we refuse to submit so the operator
// always has audit context for a short pickup.
if (isPartial && !partialJustified) {
setError('Please tell us why the quantity is short, or take a photo to continue');
return;
}
// Prefix the driver's free-text notes with the structured reason
// when provided, so the operator sees both in the problem note
// without losing the original wording. The reason goes first
// because that's the most actionable piece.
const finalNotes = isPartial && partialReason
? `[${partialReason}] ${notes}`.trim()
: notes;
setSubmitting(true);
try {
await onConfirm(item, qty, finalNotes, photo);
} finally {
setSubmitting(false);
}
};
const adjustQty = (delta: number) => {
const current = parseFloat(receivedQty) || 0;
const newQty = Math.max(0, current + delta);
setReceivedQty(newQty.toString());
setError('');
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/50 modal-overlay" onClick={onClose} />
<div className="relative bg-[#e6e5e0] bottom-sheet w-full max-w-md flex flex-col max-h-[calc(100dvh-1rem)] sm:max-h-[85vh] border border-[rgba(38,37,30,0.1)] animate-slide-up">
<form
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
{/* Drag indicator */}
<div className="sm:hidden flex justify-center pt-2 pb-1">
<div className="w-10 h-1 bg-[rgba(38,37,30,0.2)] rounded-full" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-6 pt-2 pb-5">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-[#e6f1ec] rounded-xl flex items-center justify-center">
<Check size={18} className="text-[#18714f]" />
</div>
<h2 className="text-lg font-bold text-[#26251e]">Collect Item</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="p-2 rounded-xl hover:bg-[rgba(38,37,30,0.06)] active:bg-[rgba(38,37,30,0.12)] transition-colors"
>
<X size={18} className="text-[rgba(38,37,30,0.55)]" />
</button>
</div>
{/* Scrollable body — overflow lives on a child so the form
can still submit via Enter / native submit semantics. */}
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-6 pb-6 space-y-5">
{/* Item info */}
<div className="p-4 bg-[#ebeae5] rounded-xl">
<p className="text-sm font-semibold text-[#26251e] truncate">{displayDescription}</p>
{item.sku && (
<p className="text-[10px] text-[rgba(38,37,30,0.5)] font-mono mt-0.5">#{item.sku}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-[rgba(38,37,30,0.55)]">
<span className="font-medium text-[#26251e]">
{item.expand?.supplier?.suppliername || 'Unknown'}
</span>
<span>Expected: {expectedQty} units</span>
</div>
{item.selling_price && (
<p className="text-xs text-[#18714f] font-medium mt-1">
Unit price: {formatCurrency(item.selling_price)}
</p>
)}
</div>
{/* Quantity */}
<div>
<label htmlFor="received-qty" className="block text-sm font-semibold text-[#26251e] mb-2">
Received Quantity *
</label>
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={() => adjustQty(-1)}
aria-label="Decrease quantity"
className="w-12 h-12 flex items-center justify-center rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-colors touch-manipulation press-feedback-sm"
>
<Minus size={18} className="text-[#26251e]" />
</button>
<input
id="received-qty"
type="number"
step="0.01"
inputMode="decimal"
value={receivedQty}
onChange={(e) => { setReceivedQty(e.target.value); setError(''); }}
className="w-20 text-center text-2xl font-bold py-3 border-2 border-[rgba(38,37,30,0.1)] rounded-xl focus:ring-2 focus:ring-[#f54e00]/30 focus:border-[#f54e00] transition-all bg-[#f2f1ed] text-[#26251e]"
placeholder="0"
autoFocus
/>
<button
type="button"
onClick={() => adjustQty(1)}
aria-label="Increase quantity"
className="w-12 h-12 flex items-center justify-center rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-colors touch-manipulation press-feedback-sm"
>
<Plus size={18} className="text-[#26251e]" />
</button>
{expectedQty > 0 && (
<button
type="button"
onClick={() => { setReceivedQty(expectedQty.toString()); setError(''); }}
className="px-3 py-2 text-xs font-medium text-[#125a3e] bg-[#e6f1ec] rounded-lg active:bg-[#c2dfd0] transition-colors touch-manipulation"
>
Full ({expectedQty})
</button>
)}
</div>
{isPartial && (
<p className="text-xs text-[#8a5a02] mt-2 font-medium">
Partial collection: {parsedQty} of {expectedQty} units
</p>
)}
{isOverQty && (
<p className="text-xs text-[#26251e] mt-2 font-medium">
Over collection: {parsedQty} exceeds expected {expectedQty}
</p>
)}
</div>
{/* Partial-collection justification. Only shown when the
driver enters a qty less than expected. We require EITHER
a picked reason OR a photo so the operator always has
some audit context for the short pickup. The reason
flows through into the auto-generated problem note. */}
{isPartial && (
<div
className="p-4 bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl space-y-2"
role="group"
aria-label="Partial collection justification"
>
<div className="flex items-center gap-2">
<AlertCircle size={14} className="text-[#8a5a02] flex-shrink-0" aria-hidden="true" />
<p className="text-xs font-semibold text-[#8a5a02]">
Why are you short? Pick one or take a photo below.
</p>
</div>
<label htmlFor="partial-reason" className="sr-only">Reason for short collection</label>
<select
id="partial-reason"
value={partialReason}
onChange={(e) => { setPartialReason(e.target.value); setError(''); }}
className={cn(
'w-full p-2.5 text-sm bg-[#f2f1ed] border rounded-lg text-[#26251e] focus:outline-none focus:ring-2 focus:ring-[#f54e00]/30 focus:border-[#f54e00] transition-all',
partialReason ? 'border-[rgba(38,37,30,0.2)]' : 'border-[#cf2d56]/40'
)}
aria-required="true"
>
<option value="">Select a reason…</option>
<option value="Out of stock">Supplier out of stock</option>
<option value="Wrong item">Wrong item received</option>
<option value="Quality issue">Quality issue</option>
<option value="Damaged">Damaged goods</option>
<option value="Wrong qty packed">Wrong qty packed</option>
<option value="Other">Other (specify in notes)</option>
</select>
{!partialReason && !photo && (
<p className="text-[10px] text-[#8a5a02]">
Required: pick a reason or attach a photo below to continue.
</p>
)}
</div>
)}
{/* Notes */}
<div>
<label htmlFor="collect-notes" className="block text-sm font-semibold text-[#26251e] mb-2">
Notes (optional)
</label>
<textarea
id="collect-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full p-3 text-sm bg-[#f2f1ed] border border-[rgba(38,37,30,0.1)] rounded-xl text-[#26251e] placeholder:text-[rgba(38,37,30,0.4)] focus:ring-2 focus:ring-[#f54e00]/30 focus:border-[#f54e00] focus:bg-[#f7f7f4] transition-all resize-none selectable"
rows={2}
placeholder="Add any notes about this collection..."
/>
</div>
{/* Photo (optional) — proof-of-pickup. Driver takes a
snapshot of the goods so operators have a visual
record when a quantity mismatch gets reported later. */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-semibold text-[#26251e]">
Photo (optional)
</label>
<span className="text-[10px] text-[rgba(38,37,30,0.5)] uppercase tracking-wider">
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]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoPreviewUrl}
alt="Pickup photo preview"
className="w-full h-48 object-cover"
/>
<button
type="button"
onClick={handleRemovePhoto}
aria-label="Remove photo"
className="absolute top-2 right-2 w-9 h-9 rounded-full bg-[rgba(38,37,30,0.85)] text-white flex items-center justify-center active:bg-[rgba(38,37,30,1)] transition-colors touch-manipulation"
>
<Trash2 size={16} aria-hidden="true" />
</button>
<div className="px-3 py-2 text-[11px] text-[rgba(38,37,30,0.55)] flex items-center justify-between">
<span className="truncate">{photo?.name}</span>
<span className="flex-shrink-0 ml-2 font-mono">
{photo ? `${(photo.size / 1024).toFixed(0)}KB` : ''}
</span>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{/* capture="environment" hints the camera (rear-facing)
on mobile. Desktop browsers ignore the attribute
and show the standard file picker. */}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="sr-only"
onChange={(e) => handlePhotoSelected(e.target.files?.[0])}
/>
<input
ref={libraryInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => handlePhotoSelected(e.target.files?.[0])}
/>
<button
type="button"
onClick={() => cameraInputRef.current?.click()}
className="flex flex-col items-center justify-center gap-1.5 py-4 rounded-xl border-2 border-dashed border-[rgba(38,37,30,0.18)] bg-[#ebeae5] active:bg-[#e1e0db] transition-colors touch-manipulation press-feedback-sm min-h-[88px]"
>
<Camera size={20} className="text-[#26251e]" aria-hidden="true" />
<span className="text-xs font-semibold text-[#26251e]">Take Photo</span>
</button>
<button
type="button"
onClick={() => libraryInputRef.current?.click()}
className="flex flex-col items-center justify-center gap-1.5 py-4 rounded-xl border-2 border-dashed border-[rgba(38,37,30,0.18)] bg-[#ebeae5] active:bg-[#e1e0db] transition-colors touch-manipulation press-feedback-sm min-h-[88px]"
>
<ImagePlus size={20} className="text-[#26251e]" aria-hidden="true" />
<span className="text-xs font-semibold text-[#26251e]">From Library</span>
</button>
</div>
)}
</div>
{error && <p className="text-xs text-[#b3224a]" role="alert">{error}</p>}
</div>
{/* Pinned footer — sits above BottomNav (z-50). pb accounts for
the 4rem nav height + safe-area inset + visual gap so the
button never hides under the tab bar. */}
<div
className="px-6 pt-3 pb-[calc(4rem+env(safe-area-inset-bottom,0px)+0.75rem)] border-t border-[rgba(38,37,30,0.08)] bg-[#e6e5e0] flex-shrink-0"
style={{ boxShadow: '0 -8px 24px rgba(0,0,0,0.06)' }}
>
<button
type="submit"
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] 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)' }}
>
{submitting ? (
<>
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
{photo ? 'Saving & uploading photo…' : 'Saving…'}
</>
) : (
'Confirm Collection'
)}
</button>
</div>
</form>
</div>
</div>
);
}