'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; /** 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(null); const [photoPreviewUrl, setPhotoPreviewUrl] = useState(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(null); const libraryInputRef = useRef(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 (
{/* Drag indicator */}
{/* Header */}

Collect Item

{/* Scrollable body — overflow lives on a child so the form can still submit via Enter / native submit semantics. */}
{/* Item info */}

{displayDescription}

{item.sku && (

#{item.sku}

)}
{item.expand?.supplier?.suppliername || 'Unknown'} Expected: {expectedQty} units
{item.selling_price && (

Unit price: {formatCurrency(item.selling_price)}

)}
{/* Quantity */}
{ 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 /> {expectedQty > 0 && ( )}
{isPartial && (

Partial collection: {parsedQty} of {expectedQty} units

)} {isOverQty && (

Over collection: {parsedQty} exceeds expected {expectedQty}

)}
{/* 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 && (
{!partialReason && !photo && (

Required: pick a reason or attach a photo below to continue.

)}
)} {/* Notes */}