- add optional collection_photo field to OrderItem type and backend support for pickup photo uploads - implement in-memory + localStorage cache for order items to eliminate tab-switch loading skeletons - update service worker to use stale-while-revalidate for navigation requests to reduce perceived lag - fix LocationTracker to only activate when logged in and hoist to root layout for persistent tracking - adjust InstallPrompt positioning to avoid overlapping bottom navigation - throttle map cluster recomputation to reduce unnecessary work during pan/zoom - clear cached items on logout to prevent cross-driver data leakage
312 lines
No EOL
14 KiB
TypeScript
312 lines
No EOL
14 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 } from 'lucide-react';
|
|
|
|
interface CollectModalProps {
|
|
item: OrderItem | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onConfirm: (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => void;
|
|
}
|
|
|
|
// Max photo size we'll accept in the UI (10MB on PB side too).
|
|
// Reject anything larger with an inline error so the driver sees
|
|
// the constraint before PB does — PB's error message is opaque.
|
|
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,
|
|
}: 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);
|
|
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;
|
|
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 = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
|
|
const qty = parseFloat(receivedQty);
|
|
if (isNaN(qty) || qty <= 0) {
|
|
setError('Please enter a valid quantity');
|
|
return;
|
|
}
|
|
|
|
onConfirm(item, qty, notes, photo);
|
|
};
|
|
|
|
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>
|
|
|
|
{/* 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>
|
|
|
|
{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"
|
|
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) && 'opacity-50'
|
|
)}
|
|
style={{ boxShadow: '0 4px 12px rgba(31,138,101,0.3)' }}
|
|
>
|
|
Confirm Collection
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |