feat: add geographic grouping, refactor collection workflow
- Add standardized item display helpers for consistent labeling across components - Rework PocketBase data fetching into scoped assigned/pool/problem item queries - Add proximity-based clustering and AreaGroup component for geographic list grouping - Sync map page tab state with main collections list using localStorage - Update all existing components to use new data and display patterns - Add custom map marker, tooltip, and cluster styling
This commit is contained in:
parent
b80551fe4d
commit
9a99c489c3
11 changed files with 1485 additions and 270 deletions
183
app/globals.css
183
app/globals.css
|
|
@ -159,6 +159,189 @@ button, a, nav {
|
|||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Supplier item-name tooltip — Cursor-harmonized: warm cream surface
|
||||
with a hairline border and tight radius matching the rest of the
|
||||
app. Default Leaflet tooltip is white with a hard tip — wrong tone. */
|
||||
.supplier-tooltip {
|
||||
background: #e6e5e0 !important;
|
||||
color: #26251e !important;
|
||||
border: 1px solid rgba(38, 37, 30, 0.1) !important;
|
||||
border-radius: 6px !important;
|
||||
font-family: system-ui, -apple-system, sans-serif !important;
|
||||
padding: 4px 8px !important;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.18) !important;
|
||||
white-space: nowrap !important;
|
||||
max-width: 240px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
.supplier-tooltip::before {
|
||||
border-top-color: #e6e5e0 !important;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────────
|
||||
Waypoint pill — the new unified marker shape. Leaflet wraps each
|
||||
divIcon in a `.leaflet-marker-icon` container that ships with
|
||||
`text-align: center` and a default white background. We override
|
||||
so the pill:
|
||||
• renders the actual pill (no default white box behind it)
|
||||
• centers on the lat/lng (Leaflet applies the iconAnchor already)
|
||||
• never gets selected or dragged-text by accident
|
||||
• keeps its high z-index so it stacks above polylines
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
.waypoint-div-icon {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Waypoint map marker — consistent circle for every stop.
|
||||
Three reasons:
|
||||
1. At 10+ stops pill-shaped icons crowded and collided. A small
|
||||
identical circle scales gracefully at every density.
|
||||
2. Driver reads shape — a denser region of dots = more pickups,
|
||||
not "many different icon types to parse."
|
||||
3. Cluster count lives in a small corner badge so the silhouette
|
||||
of "1 stop" vs "9 stops at same supplier" stays identical.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
.waypoint-div-icon {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
.waypoint-div-icon .waypoint-dot-wrap {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Soft halo: sits behind the dot, gives it a faint presence on the
|
||||
map without competing with the dot's silhouette. */
|
||||
.waypoint-div-icon .waypoint-dot-shadow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -14px 0 0 -14px;
|
||||
border-radius: 50%;
|
||||
background: rgba(245, 78, 0, 0.18);
|
||||
filter: blur(2px);
|
||||
transition: transform 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
/* The dot itself — 26px circle, brand orange fill, cream ring for
|
||||
contrast on light tiles, subtle dark ring for depth on darks. */
|
||||
.waypoint-div-icon .waypoint-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: -13px 0 0 -13px;
|
||||
border-radius: 50%;
|
||||
background: #f54e00;
|
||||
border: 3px solid #f2f1ed;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(38, 37, 30, 0.25),
|
||||
0 4px 10px rgba(38, 37, 30, 0.35);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
/* Cluster count badge — small pill in the top-right corner of the
|
||||
hit area. Only ever shown on multi-point clusters; for singletons
|
||||
we leave the slot empty so the silhouette stays minimal. */
|
||||
.waypoint-div-icon .waypoint-dot-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #26251e;
|
||||
color: #f2f1ed;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #f2f1ed;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none; /* clicks pass through to the dot */
|
||||
}
|
||||
/* Singleton name label — sits below the dot, only when there's exactly
|
||||
one stop here. Hidden when a count badge is present so the dot's
|
||||
footprint stays small in dense regions. */
|
||||
.waypoint-div-icon .waypoint-dot-name {
|
||||
position: absolute;
|
||||
top: calc(50% + 16px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 140px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(38, 37, 30, 0.78);
|
||||
color: #f2f1ed;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.waypoint-div-icon:hover .waypoint-dot-name {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Hover state — dot scales up 1.15× and lifts, badge pops slightly. */
|
||||
.waypoint-div-icon:hover .waypoint-dot,
|
||||
.waypoint-div-icon:focus-visible .waypoint-dot {
|
||||
transform: scale(1.15);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(38, 37, 30, 0.3),
|
||||
0 7px 16px rgba(245, 78, 0, 0.4),
|
||||
0 4px 8px rgba(38, 37, 30, 0.3);
|
||||
}
|
||||
.waypoint-div-icon:hover .waypoint-dot-shadow {
|
||||
transform: scale(1.4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* Keyboard focus: the hit box is the only focusable surface. */
|
||||
.waypoint-div-icon:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.waypoint-div-icon:focus-visible .waypoint-dot {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(245, 78, 0, 0.4),
|
||||
0 0 0 1px rgba(38, 37, 30, 0.3),
|
||||
0 4px 10px rgba(38, 37, 30, 0.35);
|
||||
}
|
||||
/* Active press: dot squashes slightly to confirm the click. */
|
||||
.waypoint-div-icon:active .waypoint-dot {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
/* Respect prefers-reduced-motion: drop the easing and transforms
|
||||
so users sensitive to motion still see the waypoints clearly. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.waypoint-div-icon .waypoint-dot,
|
||||
.waypoint-div-icon .waypoint-dot-shadow,
|
||||
.waypoint-div-icon .waypoint-dot-name {
|
||||
transition: none !important;
|
||||
}
|
||||
.waypoint-div-icon:hover .waypoint-dot,
|
||||
.waypoint-div-icon:focus-visible .waypoint-dot,
|
||||
.waypoint-div-icon:hover .waypoint-dot-shadow {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Animations
|
||||
======================================== */
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ import { OrderItem } from '@/lib/types';
|
|||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import LocationTracker from '@/components/LocationTracker';
|
||||
import { getAssignedCollections, isAuthenticated } from '@/lib/pocketbase';
|
||||
import { getMyAssignedItems, getPoolItems, getProblemItems, isAuthenticated } from '@/lib/pocketbase';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RefreshCw, MapPin, AlertCircle } from 'lucide-react';
|
||||
import { RefreshCw, MapPin, AlertCircle, Package, Inbox, AlertTriangle } from 'lucide-react';
|
||||
|
||||
// Sync the map's dataset with the list page's selected tab via
|
||||
// localStorage so a driver who flips to "Pool" on the list and then
|
||||
// taps Map sees the same pool stops, not just their assigned list.
|
||||
const MAP_TAB_KEY = '1driver_map_tab';
|
||||
type MapTab = 'assigned' | 'collection_pool' | 'problem';
|
||||
|
||||
const CollectionMap = dynamic(() => import('@/components/CollectionMap'), {
|
||||
ssr: false,
|
||||
|
|
@ -56,6 +62,16 @@ export default function MapPage() {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [tab, setTab] = useState<MapTab>('assigned');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = window.localStorage.getItem(MAP_TAB_KEY);
|
||||
if (saved === 'assigned' || saved === 'collection_pool' || saved === 'problem') {
|
||||
setTab(saved);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) router.push('/login/');
|
||||
|
|
@ -66,7 +82,10 @@ export default function MapPage() {
|
|||
if (showLoading) setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getAssignedCollections();
|
||||
let data: OrderItem[] = [];
|
||||
if (tab === 'assigned') data = await getMyAssignedItems();
|
||||
else if (tab === 'collection_pool') data = await getPoolItems();
|
||||
else data = await getProblemItems();
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
|
|
@ -75,9 +94,16 @@ export default function MapPage() {
|
|||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { loadItems(); }, [loadItems]);
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') window.localStorage.setItem(MAP_TAB_KEY, tab);
|
||||
loadItems();
|
||||
}, [tab, loadItems]);
|
||||
|
||||
const handleTabChange = (next: MapTab) => {
|
||||
setTab(next);
|
||||
};
|
||||
|
||||
const handleNavigate = (lat: number, lng: number, name: string) => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
|
@ -105,24 +131,53 @@ export default function MapPage() {
|
|||
<LocationTracker />
|
||||
|
||||
<header className="bg-[#e6e5e0] text-[#26251e] safe-top z-40 border-b border-[rgba(38,37,30,0.1)] flex-shrink-0">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center">
|
||||
<MapPin size={18} className="text-[#26251e]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold leading-tight text-[#26251e]">Map</h1>
|
||||
<p className="text-[11px] text-[rgba(38,37,30,0.55)]">{items.length} stop{items.length !== 1 ? 's' : ''}</p>
|
||||
<div className="px-4 pt-2 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<MapPin size={18} className="text-[#26251e]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold leading-tight text-[#26251e]">Map</h1>
|
||||
<p className="text-[11px] text-[rgba(38,37,30,0.55)]">{items.length} stop{items.length !== 1 ? 's' : ''} on map</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsRefreshing(true); loadItems(false); }}
|
||||
disabled={isRefreshing}
|
||||
aria-label={isRefreshing ? 'Refreshing map' : 'Refresh map'}
|
||||
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation flex-shrink-0"
|
||||
>
|
||||
<RefreshCw size={18} className={cn('text-[#26251e]', isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher — same semantics as the list page so the map
|
||||
shows whatever scope the driver is currently working on. */}
|
||||
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto no-scrollbar -mx-1" role="tablist" aria-label="Map scope">
|
||||
{([
|
||||
{ key: 'assigned', label: 'My stops', Icon: Package },
|
||||
{ key: 'collection_pool', label: 'Pool', Icon: Inbox },
|
||||
{ key: 'problem', label: 'Problems', Icon: AlertTriangle },
|
||||
] as const).map(({ key, label, Icon }) => {
|
||||
const active = tab === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => handleTabChange(key)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold transition-all touch-manipulation flex-shrink-0',
|
||||
active ? 'bg-[#26251e] text-[#f2f1ed]' : 'bg-[rgba(38,37,30,0.06)] text-[rgba(38,37,30,0.7)]'
|
||||
)}
|
||||
>
|
||||
<Icon size={11} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsRefreshing(true); loadItems(false); }}
|
||||
disabled={isRefreshing}
|
||||
aria-label={isRefreshing ? 'Refreshing map' : 'Refresh map'}
|
||||
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation"
|
||||
>
|
||||
<RefreshCw size={18} className={cn('text-[#26251e]', isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
209
app/page.tsx
209
app/page.tsx
|
|
@ -8,16 +8,19 @@ import BottomNav from '@/components/BottomNav';
|
|||
import LocationTracker from '@/components/LocationTracker';
|
||||
import CollectionItemCard from '@/components/CollectionItemCard';
|
||||
import SupplierGroup from '@/components/SupplierGroup';
|
||||
import AreaGroup from '@/components/AreaGroup';
|
||||
import CollectModal from '@/components/CollectModal';
|
||||
import ProblemModal from '@/components/ProblemModal';
|
||||
import {
|
||||
getAssignedCollections,
|
||||
getMyAssignedItems,
|
||||
getPoolItems,
|
||||
getProblemItems,
|
||||
updateCollectionStatus,
|
||||
reportProblem,
|
||||
isAuthenticated,
|
||||
logout,
|
||||
} from '@/lib/pocketbase';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, clusterByProximity } from '@/lib/utils';
|
||||
import {
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
|
|
@ -27,8 +30,7 @@ import {
|
|||
Search,
|
||||
X,
|
||||
Package,
|
||||
LayoutList,
|
||||
Columns,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Cursor-harmonized supplier color palette: same warm cream surface
|
||||
|
|
@ -43,7 +45,7 @@ const SUPPLIER_COLORS = [
|
|||
{ dot: 'bg-[#8a5a02]', light: 'bg-[#fcf3d8]', text: 'text-[#8a5a02]', ring: 'ring-[#d49103]/30' }, // amber
|
||||
{ dot: 'bg-[#26251e]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#26251e]/30' }, // ink
|
||||
{ dot: 'bg-[#6b6760]', light: 'bg-[#ebeae5]', text: 'text-[#4a4742]', ring: 'ring-[#6b6760]/30' }, // warm gray
|
||||
{ dot: 'bg-[#18714f]', light: 'bg-[#e6f1ec]', text: 'text-[#125a3e]', ring: 'ring-[#18714f]/30' }, // teal-ish
|
||||
{ dot: 'bg-[#0d9488]', light: 'bg-[#e6f5f3]', text: 'text-[#0a7a70]', ring: 'ring-[#0d9488]/30' }, // teal
|
||||
{ dot: 'bg-[#8a1b3a]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // crimson
|
||||
{ dot: 'bg-[#4a3d6b]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // indigo
|
||||
];
|
||||
|
|
@ -62,29 +64,46 @@ export default function CollectionsPage() {
|
|||
const [showProblemModal, setShowProblemModal] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'assigned' | 'driver_pool' | 'problem'>('all');
|
||||
const [groupMode, setGroupMode] = useState<'supplier' | 'area'>('supplier');
|
||||
const [statusFilter, setStatusFilter] = useState<'assigned' | 'collection_pool' | 'problem'>('assigned');
|
||||
|
||||
// Track which supplier groups are expanded
|
||||
const [expandedSuppliers, setExpandedSuppliers] = useState<Set<string>>(new Set());
|
||||
const [expandedAreas, setExpandedAreas] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) router.push('/login/');
|
||||
}, [isLoggedIn, authLoading, router]);
|
||||
|
||||
// Each tab maps to a different PocketBase fetch — pool and problems
|
||||
// are SYSTEM-WIDE (any driver can see them), assigned is the current
|
||||
// driver's personal list only. This is the contract the brief asked
|
||||
// for: "pool must show all items in collection_pool, same with
|
||||
// problems; default when you open the app should be assigned".
|
||||
const loadItems = useCallback(async (showLoading = true) => {
|
||||
if (!isAuthenticated()) return;
|
||||
if (showLoading) setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getAssignedCollections();
|
||||
let data: OrderItem[] = [];
|
||||
if (statusFilter === 'assigned') {
|
||||
data = await getMyAssignedItems();
|
||||
} else if (statusFilter === 'collection_pool') {
|
||||
data = await getPoolItems();
|
||||
} else if (statusFilter === 'problem') {
|
||||
data = await getProblemItems();
|
||||
}
|
||||
setItems(data);
|
||||
setFilteredItems(data);
|
||||
// Auto-expand all suppliers on fresh load
|
||||
const allIds = new Set<string>();
|
||||
const allSupplierIds = new Set<string>();
|
||||
data.forEach(i => {
|
||||
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
|
||||
if (i.expand?.supplier?.id) allSupplierIds.add(i.expand.supplier.id);
|
||||
});
|
||||
setExpandedSuppliers(allIds);
|
||||
setExpandedSuppliers(allSupplierIds);
|
||||
const allAreaIds = new Set<string>();
|
||||
const clusters = clusterByProximity(data, 2);
|
||||
clusters.forEach(c => allAreaIds.add(c.id));
|
||||
setExpandedAreas(allAreaIds);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
setError('Failed to load collections. Pull down to retry.');
|
||||
|
|
@ -92,15 +111,12 @@ export default function CollectionsPage() {
|
|||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { loadItems(); }, [loadItems]);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = items;
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter((i) => i.item_status === statusFilter || (statusFilter === 'problem' && i.problem_flag));
|
||||
}
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((i) => {
|
||||
|
|
@ -119,13 +135,24 @@ export default function CollectionsPage() {
|
|||
|
||||
// Counts for the status chip strip — derived from the unfiltered
|
||||
// list so chips stay stable as the user types in the search field.
|
||||
// Each tab pulls from a different PB query (driver-scoped vs system-wide),
|
||||
// so these counts aren't comparable across tabs; they each represent
|
||||
// the size of the most recent fetch for THAT tab.
|
||||
const statusCounts = useMemo(() => ({
|
||||
all: items.length,
|
||||
assigned: items.filter((i) => i.item_status === 'assigned').length,
|
||||
driver_pool: items.filter((i) => i.item_status === 'driver_pool').length,
|
||||
collection_pool: items.filter((i) => i.item_status === 'collection_pool').length,
|
||||
problem: items.filter((i) => i.problem_flag).length,
|
||||
}), [items]);
|
||||
|
||||
// Mirror the currently selected tab to localStorage so the Map page
|
||||
// can pick it up on its next visit and show the same dataset the
|
||||
// driver was last working with.
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('1driver_map_tab', statusFilter);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await loadItems(false);
|
||||
|
|
@ -179,12 +206,27 @@ export default function CollectionsPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
const allIds = new Set<string>();
|
||||
filteredItems.forEach(i => {
|
||||
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
|
||||
const toggleArea = (clusterId: string) => {
|
||||
setExpandedAreas(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(clusterId)) next.delete(clusterId);
|
||||
else next.add(clusterId);
|
||||
return next;
|
||||
});
|
||||
setExpandedSuppliers(allIds);
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
if (groupMode === 'supplier') {
|
||||
const allIds = new Set<string>();
|
||||
filteredItems.forEach(i => {
|
||||
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
|
||||
});
|
||||
setExpandedSuppliers(allIds);
|
||||
} else {
|
||||
const allIds = new Set<string>();
|
||||
areaClusters.forEach(c => allIds.add(c.id));
|
||||
setExpandedAreas(allIds);
|
||||
}
|
||||
};
|
||||
|
||||
const collapseAll = () => setExpandedSuppliers(new Set());
|
||||
|
|
@ -204,6 +246,11 @@ export default function CollectionsPage() {
|
|||
);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Group by geographic proximity — items within 2km share a cluster color
|
||||
const areaClusters = useMemo(() => {
|
||||
return clusterByProximity(filteredItems, 2);
|
||||
}, [filteredItems]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f2f1ed]">
|
||||
|
|
@ -297,11 +344,43 @@ export default function CollectionsPage() {
|
|||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div
|
||||
className="flex items-center gap-1 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5 flex-shrink-0"
|
||||
role="group"
|
||||
aria-label="Item density"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Group mode toggle */}
|
||||
<div
|
||||
className="flex items-center gap-0.5 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5"
|
||||
role="group"
|
||||
aria-label="Group by"
|
||||
>
|
||||
<button
|
||||
onClick={() => setGroupMode('supplier')}
|
||||
aria-pressed={groupMode === 'supplier'}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
|
||||
groupMode === 'supplier' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
|
||||
)}
|
||||
>
|
||||
<MapPin size={10} aria-hidden="true" />
|
||||
Supplier
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGroupMode('area')}
|
||||
aria-pressed={groupMode === 'area'}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
|
||||
groupMode === 'area' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
|
||||
)}
|
||||
>
|
||||
<Layers size={10} aria-hidden="true" />
|
||||
Area
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View density toggle */}
|
||||
<div
|
||||
className="flex items-center gap-1 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5"
|
||||
role="group"
|
||||
aria-label="Item density"
|
||||
>
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
aria-pressed={viewMode === 'compact'}
|
||||
|
|
@ -324,6 +403,7 @@ export default function CollectionsPage() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status filter chips — show counts of each state so drivers
|
||||
can see at a glance what's pending vs flagged. */}
|
||||
|
|
@ -333,9 +413,8 @@ export default function CollectionsPage() {
|
|||
aria-label="Filter by status"
|
||||
>
|
||||
{([
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'assigned', label: 'Assigned' },
|
||||
{ key: 'driver_pool', label: 'Pool' },
|
||||
{ key: 'collection_pool', label: 'Pool' },
|
||||
{ key: 'problem', label: 'Problems' },
|
||||
] as const).map(({ key, label }) => {
|
||||
const count = statusCounts[key];
|
||||
|
|
@ -397,43 +476,69 @@ export default function CollectionsPage() {
|
|||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : groupedArray.length === 0 ? (
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="text-center py-16 px-4">
|
||||
<div className="w-16 h-16 bg-[#e6f1ec] rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 size={32} className="text-[#18714f]" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-[#26251e] font-semibold text-lg mb-1">
|
||||
{searchQuery || statusFilter !== 'all' ? 'Nothing matches' : 'All caught up'}
|
||||
{searchQuery ? 'Nothing matches' : 'All caught up'}
|
||||
</p>
|
||||
<p className="text-[rgba(38,37,30,0.55)] text-sm">
|
||||
{searchQuery
|
||||
? 'No collections match your search.'
|
||||
: statusFilter !== 'all'
|
||||
? `No ${statusFilter.replace('_', ' ')} items in your list.`
|
||||
: 'No assigned collections right now. New pickups will appear here.'}
|
||||
: statusFilter === 'assigned'
|
||||
? 'No assigned collections right now. New pickups will appear here.'
|
||||
: statusFilter === 'collection_pool'
|
||||
? 'Collection pool is empty — nothing to claim right now.'
|
||||
: 'No problems reported. Nice work.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => {
|
||||
const color = SUPPLIER_COLORS[groupIdx % SUPPLIER_COLORS.length];
|
||||
const isExpanded = expandedSuppliers.has(supplier!.id);
|
||||
{groupMode === 'supplier' ? (
|
||||
groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => {
|
||||
const color = SUPPLIER_COLORS[groupIdx % SUPPLIER_COLORS.length];
|
||||
const isExpanded = expandedSuppliers.has(supplier!.id);
|
||||
|
||||
return (
|
||||
<SupplierGroup
|
||||
key={supplier!.id}
|
||||
supplier={supplier!}
|
||||
items={supplierItems}
|
||||
color={color}
|
||||
isExpanded={isExpanded}
|
||||
viewMode={viewMode}
|
||||
onToggle={() => toggleSupplier(supplier!.id)}
|
||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<SupplierGroup
|
||||
key={supplier!.id}
|
||||
supplier={supplier!}
|
||||
items={supplierItems}
|
||||
color={color}
|
||||
isExpanded={isExpanded}
|
||||
viewMode={viewMode}
|
||||
onToggle={() => toggleSupplier(supplier!.id)}
|
||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
areaClusters.map((cluster, clusterIdx) => {
|
||||
const color = SUPPLIER_COLORS[clusterIdx % SUPPLIER_COLORS.length];
|
||||
const isExpanded = expandedAreas.has(cluster.id);
|
||||
|
||||
return (
|
||||
<AreaGroup
|
||||
key={cluster.id}
|
||||
clusterId={cluster.id}
|
||||
label={cluster.label}
|
||||
items={cluster.items}
|
||||
center={cluster.center}
|
||||
color={color}
|
||||
isExpanded={isExpanded}
|
||||
viewMode={viewMode}
|
||||
onToggle={() => toggleArea(cluster.id)}
|
||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
234
components/AreaGroup.tsx
Normal file
234
components/AreaGroup.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import CollectionItemCard from '@/components/CollectionItemCard';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Hammer,
|
||||
Wrench,
|
||||
AlertTriangle,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AreaColor {
|
||||
dot: string;
|
||||
light: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
interface AreaGroupProps {
|
||||
clusterId: string;
|
||||
label: string;
|
||||
items: OrderItem[];
|
||||
center: { latitude: number; longitude: number };
|
||||
color: AreaColor;
|
||||
isExpanded: boolean;
|
||||
viewMode: 'compact' | 'detailed';
|
||||
onToggle: () => void;
|
||||
onCollect: (item: OrderItem) => void;
|
||||
onProblem: (item: OrderItem) => void;
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}
|
||||
|
||||
function getItemIcon(item: OrderItem) {
|
||||
if (item.problem_flag) return <AlertTriangle size={12} className="text-red-500" />;
|
||||
const orderNumber = item.expand?.order?.order_number || '';
|
||||
if (orderNumber.toLowerCase().includes('breakdown')) return <Hammer size={12} className="text-orange-500" />;
|
||||
if (orderNumber.toLowerCase().includes('repair')) return <Wrench size={12} className="text-amber-500" />;
|
||||
if (item.is_stock_item) return <Package size={12} className="text-violet-500" />;
|
||||
return <ShoppingCart size={12} className="text-gray-400" />;
|
||||
}
|
||||
|
||||
export default function AreaGroup({
|
||||
clusterId,
|
||||
label,
|
||||
items,
|
||||
center,
|
||||
color,
|
||||
isExpanded,
|
||||
viewMode,
|
||||
onToggle,
|
||||
onCollect,
|
||||
onProblem,
|
||||
onNavigate,
|
||||
}: AreaGroupProps) {
|
||||
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
|
||||
|
||||
const uniqueSuppliers = Array.from(
|
||||
new Set(
|
||||
items
|
||||
.map(i => i.expand?.supplier?.suppliername)
|
||||
.filter((n): n is string => !!n)
|
||||
)
|
||||
);
|
||||
|
||||
const hasCoords = center.latitude !== 0 && center.longitude !== 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden transition-all duration-200 border',
|
||||
color.light,
|
||||
`border-[rgba(38,37,30,0.1)]`,
|
||||
`ring-1 ${color.ring}`
|
||||
)}
|
||||
aria-label={`${label} area group`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`area-${clusterId}-items`}
|
||||
className="w-full text-left p-4 active:bg-[#ebeae5] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', color.dot)} aria-hidden="true">
|
||||
<Layers size={16} className="text-white" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className={cn('font-semibold text-[15px] truncate leading-snug', color.text)}>
|
||||
{label}
|
||||
</h2>
|
||||
{uniqueSuppliers.length > 1 ? (
|
||||
<p className="text-[12px] text-[rgba(38,37,30,0.55)] truncate mt-0.5">
|
||||
{uniqueSuppliers.join(' · ')}
|
||||
</p>
|
||||
) : uniqueSuppliers.length === 1 ? (
|
||||
<p className="text-[12px] text-[rgba(38,37,30,0.55)] truncate mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={10} aria-hidden="true" />
|
||||
{uniqueSuppliers[0]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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' : ''}
|
||||
</span>
|
||||
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">{totalQty} total</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-7 h-7" aria-hidden="true">
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={18} className="text-[rgba(38,37,30,0.55)]" />
|
||||
) : (
|
||||
<ChevronDown size={18} className="text-[rgba(38,37,30,0.55)]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'compact' && !isExpanded && (
|
||||
<div className="mt-2 ml-12 flex items-center gap-1.5 flex-wrap">
|
||||
{items.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 && ','}
|
||||
</span>
|
||||
))}
|
||||
{items.length > 3 && (
|
||||
<span className="text-[10px] text-[rgba(38,37,30,0.4)] font-medium">
|
||||
+{items.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
id={`area-${clusterId}-items`}
|
||||
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
|
||||
>
|
||||
{viewMode === 'compact' ? (
|
||||
<ul className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
|
||||
{items.map((item) => {
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{getItemIcon(item)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-[#26251e] truncate block">
|
||||
{item.description || 'Unknown item'}
|
||||
</span>
|
||||
{item.expand?.supplier?.suppliername && (
|
||||
<span className="text-[10px] text-[rgba(38,37,30,0.45)]">
|
||||
{item.expand.supplier.suppliername}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.problem_flag && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] font-medium flex-shrink-0">
|
||||
Problem
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0 ml-2">
|
||||
<span className="text-sm font-semibold text-[rgba(38,37,30,0.7)]">{expectedQty}×</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onCollect(item)}
|
||||
aria-label={`Collect ${item.description || 'item'}`}
|
||||
className="w-10 h-10 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProblem(item)}
|
||||
aria-label={`Report problem for ${item.description || 'item'}`}
|
||||
className="w-10 h-10 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1b3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="p-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<CollectionItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
supplierColor={color}
|
||||
compact
|
||||
onCollect={onCollect}
|
||||
onProblem={onProblem}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCoords && (
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
onClick={() => onNavigate(center.latitude, center.longitude, label)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white transition-all touch-manipulation press-feedback min-h-[44px]',
|
||||
color.dot
|
||||
)}
|
||||
>
|
||||
<Navigation size={14} aria-hidden="true" />
|
||||
Navigate to {label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react';
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { cn, formatCurrency } from '@/lib/utils';
|
||||
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
|
||||
import { X, Minus, Plus, Check } from 'lucide-react';
|
||||
|
||||
interface CollectModalProps {
|
||||
|
|
@ -24,10 +25,11 @@ export default function CollectModal({
|
|||
|
||||
if (!isOpen || !item) return null;
|
||||
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
const expectedQty = getItemDisplayQty(item);
|
||||
const parsedQty = parseFloat(receivedQty) || 0;
|
||||
const isPartial = parsedQty > 0 && parsedQty < expectedQty;
|
||||
const isOverQty = parsedQty > expectedQty;
|
||||
const displayDescription = getItemDisplayDescription(item);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -88,7 +90,10 @@ export default function CollectModal({
|
|||
<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">{item.description}</p>
|
||||
<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'}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react';
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
|
||||
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
|
||||
import {
|
||||
Check,
|
||||
AlertTriangle,
|
||||
|
|
@ -99,8 +100,11 @@ export default function CollectionItemCard({
|
|||
const itemType = getItemType(item);
|
||||
const coords = parseCoordinates(supplier?.coordinates);
|
||||
const hasCoords = coords !== null;
|
||||
const description = item.description || 'Unknown item';
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
// Per the project brief: prefer the supplier-facing description on the
|
||||
// pickup screen; fall back to the customer-facing description.
|
||||
const description = getItemDisplayDescription(item);
|
||||
// Per the brief: prefer order_qty; falls back to quantity.
|
||||
const expectedQty = getItemDisplayQty(item);
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -134,6 +138,16 @@ export default function CollectionItemCard({
|
|||
</h3>
|
||||
</div>
|
||||
|
||||
{/* If the customer-facing description differs from the
|
||||
supplier-facing one we just rendered, surface the
|
||||
customer one as a smaller sub-line so the driver can
|
||||
spot a discrepancy on pickup. */}
|
||||
{item.description && item.po_description && item.description.trim() !== item.po_description.trim() && (
|
||||
<p className="text-[11px] text-[rgba(38,37,30,0.55)] truncate -mt-0.5 mb-0.5">
|
||||
customer line: {item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={cn(
|
||||
'text-[10px] px-2 py-0.5 rounded-full font-semibold uppercase tracking-wide',
|
||||
|
|
@ -146,10 +160,13 @@ export default function CollectionItemCard({
|
|||
{itemType.label}
|
||||
</span>
|
||||
<span className="text-xs text-[rgba(38,37,30,0.6)]">Qty: {expectedQty}</span>
|
||||
{item.collection_date && (
|
||||
<span className="text-xs text-[rgba(38,37,30,0.55)] flex items-center gap-0.5">
|
||||
<Clock size={10} aria-hidden="true" />
|
||||
{formatDate(item.collection_date)}
|
||||
{item.sku && (
|
||||
<span className="text-[10px] text-[rgba(38,37,30,0.55)] font-mono">#{item.sku}</span>
|
||||
)}
|
||||
{item.assigned_at && (
|
||||
<span className="text-[10px] text-[rgba(38,37,30,0.45)] flex items-center gap-0.5">
|
||||
<Clock size={9} aria-hidden="true" />
|
||||
assigned {formatDate(item.assigned_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,103 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, useMapEvents } from 'react-leaflet';
|
||||
import { OrderItem, Supplier } from '@/lib/types';
|
||||
import { formatCurrency, calculateDistance, parseCoordinates } from '@/lib/utils';
|
||||
import { getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase';
|
||||
import { Navigation, AlertTriangle, Route, Crosshair } from 'lucide-react';
|
||||
import { formatCurrency, calculateDistance, parseCoordinates, clusterByProximity } from '@/lib/utils';
|
||||
import { getItemDisplayDescription, getItemDisplayQty, getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase';
|
||||
import { Navigation, AlertTriangle, Route, Crosshair, Package } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
const AREA_COLORS = [
|
||||
'#f54e00', // orange
|
||||
'#18714f', // emerald
|
||||
'#6c5a99', // violet
|
||||
'#cf2d56', // rose
|
||||
'#8a5a02', // amber
|
||||
'#26251e', // ink
|
||||
'#2563eb', // blue
|
||||
'#0d9488', // teal
|
||||
'#7c3aed', // purple
|
||||
'#dc2626', // red
|
||||
];
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// ONE shape for every waypoint. Every stop — singleton or cluster —
|
||||
// uses the same 26px orange dot so the map reads as one product surface,
|
||||
// not "Leaflet default + my clusters glued on top".
|
||||
//
|
||||
// Why a circle, not a pill:
|
||||
// • At 10+ stops the pill stage was crowded — names overlapped and
|
||||
// the silhouette grew with text length, so dense regions became
|
||||
// unreadable.
|
||||
// • A consistent dot keeps the map balanced at every zoom: silhouette
|
||||
// is identical for 1 stop or 99, so the driver reads shape density
|
||||
// (= how clustered) instead of having to re-parse variable icons.
|
||||
// • Count moves to a corner badge that appears only when ≥ 2 stops
|
||||
// share the pin — the dot itself never lies about "this is a stop".
|
||||
//
|
||||
// Where the name lives:
|
||||
// • On the dot itself — only when there's exactly 1 stop here
|
||||
// (the badge slot is available, and the name fits without crowding).
|
||||
// Multi-point dots stay pure geometry; the popup names them.
|
||||
// • On hover — a tooltip shows the supplier name + count.
|
||||
// • On click — the popup lists every item at this pin.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
const DOT_PX = 26; // fixed across every waypoint — silent unity
|
||||
|
||||
const buildWaypointIcon = (count: number, supplierName: string, areaColor?: string): L.DivIcon => {
|
||||
const isMulti = count >= 2;
|
||||
const nameForSingles = (!isMulti && supplierName) ? supplierName : '';
|
||||
const dotColor = areaColor || '#f54e00';
|
||||
|
||||
const dotHtml = `
|
||||
<div class="waypoint-dot-shadow"></div>
|
||||
<div class="waypoint-dot" style="background:${dotColor};"></div>
|
||||
${nameForSingles
|
||||
? `<div class="waypoint-dot-name">${escapeHtml(nameForSingles)}</div>`
|
||||
: ''}
|
||||
${isMulti
|
||||
? `<div class="waypoint-dot-badge">${count > 99 ? '99+' : count}</div>`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
// iconSize is the hit-box around the visual. We give it a slightly
|
||||
// larger square than the dot so the hit area is generous, the badge
|
||||
// sits inside it, and stacks of identical dots near each other don't
|
||||
// fight for clicks.
|
||||
const hit = DOT_PX + 8;
|
||||
return L.divIcon({
|
||||
className: 'waypoint-div-icon',
|
||||
html: `<div class="waypoint-dot-wrap" style="width:${hit}px;height:${hit}px;">${dotHtml}</div>`,
|
||||
iconSize: [hit, hit],
|
||||
iconAnchor: [hit / 2, hit / 2],
|
||||
popupAnchor: [0, -(hit / 2) - 4],
|
||||
});
|
||||
};
|
||||
|
||||
// Escape supplier names for safe HTML interpolation inside divIcon.html.
|
||||
// (Leaflet renders this html via document.write-ish paths that can
|
||||
// break if a supplier name contains `<` or `&` — unlikely but cheap.)
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Current-location marker (you) — distinct shape so it's never confused
|
||||
// with a stop. Solid white-bordered dot + slow outer pulse.
|
||||
const currentLocationIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="position:relative;">
|
||||
<div style="width:22px;height:22px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
|
||||
<div style="width:40px;height:40px;background:rgba(234,88,12,0.15);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
||||
<div style="width:18px;height:18px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
|
||||
<div style="width:36px;height:36px;background:rgba(234,88,12,0.15);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
||||
</div>
|
||||
<style>@keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:0}}</style>`,
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
iconSize: [18, 18],
|
||||
iconAnchor: [9, 9],
|
||||
});
|
||||
|
||||
interface MapViewProps {
|
||||
|
|
@ -34,20 +105,83 @@ interface MapViewProps {
|
|||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}
|
||||
|
||||
function MapBounds({ items }: { items: OrderItem[] }) {
|
||||
// One stop = one item's supplier coordinates. Multiple items at the same
|
||||
// supplier count as multiple stops at that pin.
|
||||
interface ResolvedPoint {
|
||||
item: OrderItem;
|
||||
supplier: Supplier;
|
||||
coords: { latitude: number; longitude: number };
|
||||
}
|
||||
|
||||
// Resolve items to lat/lng; skip rows without a usable supplier location.
|
||||
function resolvePoints(items: OrderItem[]): ResolvedPoint[] {
|
||||
const out: ResolvedPoint[] = [];
|
||||
for (const item of items) {
|
||||
const supplier = item.expand?.supplier;
|
||||
if (!supplier) continue;
|
||||
const coords = parseCoordinates(supplier.coordinates);
|
||||
if (!coords) continue;
|
||||
out.push({ item, supplier, coords });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cluster radius (in pixels) — points within this pixel distance get merged.
|
||||
const CLUSTER_PX = 60;
|
||||
|
||||
interface Cluster {
|
||||
points: ResolvedPoint[];
|
||||
center: [number, number]; // average lat/lng of member points
|
||||
bounds: L.LatLngBounds;
|
||||
}
|
||||
|
||||
function clusterPoints(
|
||||
points: ResolvedPoint[],
|
||||
map: L.Map
|
||||
): Cluster[] {
|
||||
if (points.length === 0) return [];
|
||||
const projected: Array<{ p: ResolvedPoint; px: [number, number] }> = [];
|
||||
for (const p of points) {
|
||||
const latLng = L.latLng(p.coords.latitude, p.coords.longitude);
|
||||
const px = map.latLngToContainerPoint(latLng);
|
||||
projected.push({ p, px: [px.x, px.y] });
|
||||
}
|
||||
|
||||
// Grid cluster: snap each point to a grid cell of size CLUSTER_PX.
|
||||
// Two points sharing the same cell belong to the same cluster.
|
||||
const cellSize = CLUSTER_PX;
|
||||
const cellMap = new Map<string, ResolvedPoint[]>();
|
||||
for (const { p, px } of projected) {
|
||||
const cx = Math.floor(px[0] / cellSize);
|
||||
const cy = Math.floor(px[1] / cellSize);
|
||||
const key = `${cx}:${cy}`;
|
||||
if (!cellMap.has(key)) cellMap.set(key, []);
|
||||
cellMap.get(key)!.push(p);
|
||||
}
|
||||
|
||||
const clusters: Cluster[] = [];
|
||||
for (const pts of Array.from(cellMap.values())) {
|
||||
const lats = pts.map((p: ResolvedPoint) => p.coords.latitude);
|
||||
const lngs = pts.map((p: ResolvedPoint) => p.coords.longitude);
|
||||
const centerLat = lats.reduce((a: number, b: number) => a + b, 0) / lats.length;
|
||||
const centerLng = lngs.reduce((a: number, b: number) => a + b, 0) / lngs.length;
|
||||
const bounds = L.latLngBounds(pts.map((p: ResolvedPoint) => [p.coords.latitude, p.coords.longitude]));
|
||||
clusters.push({
|
||||
points: pts,
|
||||
center: [centerLat, centerLng],
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
function MapBounds({ points }: { points: ResolvedPoint[] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const validPoints = items
|
||||
.map((i) => {
|
||||
const coords = parseCoordinates(i.expand?.supplier?.coordinates);
|
||||
return coords ? [coords.latitude, coords.longitude] as [number, number] : null;
|
||||
})
|
||||
.filter((point): point is [number, number] => point !== null);
|
||||
if (validPoints.length > 0) {
|
||||
const bounds = L.latLngBounds(validPoints);
|
||||
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
|
||||
}
|
||||
}, [map, items]);
|
||||
if (points.length === 0) return;
|
||||
const bounds = L.latLngBounds(points.map((p) => [p.coords.latitude, p.coords.longitude]));
|
||||
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
|
||||
}, [map, points]);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -55,9 +189,10 @@ function RecenterButton({ center }: { center: [number, number] }) {
|
|||
const map = useMap();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => map.setView(center, map.getZoom(), { animate: true })}
|
||||
aria-label="Recenter on me"
|
||||
className="flex items-center justify-center w-10 h-10 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl active:bg-[#ebeae5] transition-all touch-manipulation press-feedback-sm"
|
||||
className="flex items-center justify-center w-10 h-10 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl active:bg-[#ebeae5] transition-all touch-manipulation press-feedback-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f54e00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f2f1ed]"
|
||||
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
||||
>
|
||||
<Crosshair size={18} className="text-[#26251e]" />
|
||||
|
|
@ -65,12 +200,228 @@ function RecenterButton({ center }: { center: [number, number] }) {
|
|||
);
|
||||
}
|
||||
|
||||
// MapFloatingControls — MUST live inside <MapContainer>. The RecenterButton
|
||||
// and TrailToggle call useMap(), which requires a leaflet context. Earlier
|
||||
// revisions tried a createPortal pattern but it failed because the
|
||||
// dynamically-created host div was removed by React 18 strict-mode double-
|
||||
// effect-invoke before the portal could populate it.
|
||||
//
|
||||
// The fix: render the FAB stack directly (no portal). The MapContainer
|
||||
// takes its children's CSS positioning seriously — we use `position:
|
||||
// absolute` on the wrapper, anchor to the map's container via the CSS
|
||||
// variables leaflet sets, and accept that the FAB stack is part of the
|
||||
// React tree rendered by MapContainer (which is fine — react-leaflet
|
||||
// renders arbitrary children inside the map div).
|
||||
function MapFloatingControls({
|
||||
userLocation,
|
||||
showTrail,
|
||||
trailCount,
|
||||
onToggleTrail,
|
||||
}: {
|
||||
userLocation: [number, number] | null;
|
||||
showTrail: boolean;
|
||||
trailCount: number;
|
||||
onToggleTrail: () => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="leaflet-fab-host"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
zIndex: 400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{userLocation && (
|
||||
<RecenterButton center={userLocation} />
|
||||
)}
|
||||
{trailCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTrail}
|
||||
aria-pressed={showTrail}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-colors press-feedback-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f54e00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f2f1ed] ${
|
||||
showTrail
|
||||
? 'bg-[#26251e] text-[#f2f1ed]'
|
||||
: 'bg-[#e6e5e0]/95 backdrop-blur-sm text-[#26251e]'
|
||||
}`}
|
||||
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
||||
>
|
||||
<Route size={14} />
|
||||
Trail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal helper: re-cluster whenever the map moves or zooms.
|
||||
|
||||
function ClusterPopupBody({
|
||||
cluster,
|
||||
userLocation,
|
||||
onNavigate,
|
||||
}: {
|
||||
cluster: Cluster;
|
||||
userLocation: [number, number] | null;
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}) {
|
||||
const supplier = cluster.points[0].supplier;
|
||||
const distance = userLocation
|
||||
? calculateDistance(userLocation[0], userLocation[1], cluster.center[0], cluster.center[1])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-w-[220px] max-w-[280px]">
|
||||
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
||||
{supplier.supplieraddress && (
|
||||
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#ebeae5] text-[10px] font-bold text-[#26251e]">
|
||||
<Package size={10} aria-hidden="true" />
|
||||
{cluster.points.length} stop{cluster.points.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{distance && (
|
||||
<span className="text-xs text-[#f54e00] font-semibold">
|
||||
{distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)} km`} away
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 mb-3 max-h-[180px] overflow-y-auto">
|
||||
{cluster.points.map((p) => (
|
||||
<div key={p.item.id} className="text-xs p-2 bg-[#ebeae5] rounded-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{p.item.problem_flag && <AlertTriangle size={12} className="text-[#b3224a] flex-shrink-0" />}
|
||||
<span className="font-medium truncate">{getItemDisplayDescription(p.item)}</span>
|
||||
{p.item.sku && (
|
||||
<span className="text-[10px] text-[rgba(38,37,30,0.5)] font-mono">#{p.item.sku}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[rgba(38,37,30,0.55)] text-[11px]">
|
||||
Qty: {getItemDisplayQty(p.item)}
|
||||
{p.item.order_qty && p.item.quantity && p.item.order_qty !== p.item.quantity && (
|
||||
<span className="ml-1 text-[rgba(38,37,30,0.4)]">
|
||||
(cust: {p.item.quantity})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate(cluster.center[0], cluster.center[1], supplier.suppliername)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-[#f54e00] text-white rounded-lg text-sm font-semibold active:bg-[#d44200] transition-colors"
|
||||
>
|
||||
<Navigation size={14} /> Navigate
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A single cluster marker. Click on a multi-point cluster zooms into
|
||||
// its bounds so the individual pins fan out; a single-point marker
|
||||
// shows the popup normally. All clusters — count 1 or 99 — render the
|
||||
// same `buildWaypointIcon` shape, so the map never mixes marker styles.
|
||||
//
|
||||
// Cluster zoom-in: we fitBounds with maxZoom 17 (street-level), then
|
||||
// panBy(-y) so the cluster settles slightly above the FAB stack
|
||||
// instead of behind it. Without the pan, the driver has to drag the
|
||||
// map before they can read the popup.
|
||||
function ClusterMarker({
|
||||
cluster,
|
||||
onNavigate,
|
||||
userLocation,
|
||||
areaColor,
|
||||
}: {
|
||||
cluster: Cluster;
|
||||
userLocation: [number, number] | null;
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
areaColor?: string;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const count = cluster.points.length;
|
||||
// Use the SUPPLIER name as the cluster's primary label. Every point
|
||||
// in this cluster shares a supplier (and in this dataset sometimes
|
||||
// shares coords), so this is the right identity — not an item SKU.
|
||||
const supplierName =
|
||||
cluster.points[0].supplier.suppliername || 'Unknown supplier';
|
||||
|
||||
const handleClick = () => {
|
||||
if (count === 1) {
|
||||
// Singleton: just open the popup at the current zoom — moving the
|
||||
// map on every single pin is disorienting when there are several
|
||||
// singletons visible. The popup itself is the action.
|
||||
const m = cluster.points[0];
|
||||
map.panTo([m.coords.latitude, m.coords.longitude], { animate: true });
|
||||
} else {
|
||||
// Multi-point: fitBounds and zoom in DEEP. maxZoom 17 is full
|
||||
// street detail; we then nudge the map down so the cluster
|
||||
// appears just below the FAB controls instead of behind them.
|
||||
map.fitBounds(cluster.bounds, {
|
||||
padding: [40, 40],
|
||||
maxZoom: 17,
|
||||
animate: true,
|
||||
});
|
||||
// After fitBounds, shift the visible center down a touch so the
|
||||
// pill badge isn't occluded by the top-right control stack.
|
||||
// setTimeout because fitBounds updates the view synchronously but
|
||||
// the panBy needs the new container size + zoom to be applied.
|
||||
setTimeout(() => {
|
||||
const size = map.getSize();
|
||||
if (size.y > 0) {
|
||||
map.panBy([0, -Math.round(size.y * 0.12)], { animate: true });
|
||||
}
|
||||
}, 60);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`w-${supplierName}-${count}`}
|
||||
position={cluster.center}
|
||||
icon={buildWaypointIcon(count, supplierName, areaColor)}
|
||||
eventHandlers={{ click: handleClick }}
|
||||
title={`${supplierName} — ${count} stop${count === 1 ? '' : 's'}`}
|
||||
keyboard
|
||||
alt={`Waypoint: ${supplierName} (${count} ${count === 1 ? 'stop' : 'stops'})`}
|
||||
>
|
||||
<Popup>
|
||||
<ClusterPopupBody cluster={cluster} userLocation={userLocation} onNavigate={onNavigate} />
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
||||
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||
const [trail, setTrail] = useState<[number, number][]>([]);
|
||||
const [showTrail, setShowTrail] = useState(true);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const points = useMemo(() => resolvePoints(items), [items]);
|
||||
|
||||
// Proximity-based area coloring — items within 2km share a color
|
||||
const areaColorMap = useMemo(() => {
|
||||
const clusters = clusterByProximity(items, 2);
|
||||
const map = new Map<string, number>();
|
||||
clusters.forEach((cluster, idx) => {
|
||||
cluster.items.forEach(item => {
|
||||
map.set(item.id, idx % AREA_COLORS.length);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [items]);
|
||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||
const mapReadyRef = useRef(false);
|
||||
|
||||
// Get current position
|
||||
useEffect(() => {
|
||||
if ('geolocation' in navigator) {
|
||||
|
|
@ -88,11 +439,11 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
const user = getCurrentUser();
|
||||
if (!user) return;
|
||||
const data = await getDriverTrail(user.id);
|
||||
const points = data
|
||||
const pts = data
|
||||
.filter((loc) => loc.latitude && loc.longitude)
|
||||
.map((loc) => [loc.latitude, loc.longitude] as [number, number]);
|
||||
if (points.length > 0) {
|
||||
setTrail(points);
|
||||
if (pts.length > 0) {
|
||||
setTrail(pts);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -104,23 +455,6 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
};
|
||||
}, [fetchTrail]);
|
||||
|
||||
// Group by supplier
|
||||
const suppliers = useMemo(() => {
|
||||
const map = new Map<string, { supplier: Supplier; items: OrderItem[]; coords: { latitude: number; longitude: number } }>();
|
||||
items.forEach((item) => {
|
||||
if (!item.expand?.supplier) return;
|
||||
const supplier = item.expand.supplier;
|
||||
const coords = parseCoordinates(supplier.coordinates);
|
||||
if (!coords) return;
|
||||
const key = supplier.id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { supplier, items: [], coords });
|
||||
}
|
||||
map.get(key)!.items.push(item);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [items]);
|
||||
|
||||
const defaultCenter: [number, number] = [-33.9249, 18.4241];
|
||||
const center = userLocation || defaultCenter;
|
||||
|
||||
|
|
@ -129,6 +463,91 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
return calculateDistance(userLocation[0], userLocation[1], coords.latitude, coords.longitude);
|
||||
};
|
||||
|
||||
// Layer that wires up clustering reactively when items change.
|
||||
//
|
||||
// Why this used to infinite-loop: the original implementation called
|
||||
// setClusters inside the moveend/zoomend handlers AND inside an
|
||||
// effect that depended on `points`. Each parent render that produced
|
||||
// a new `points` reference (or each moveend fire from Leaflet's
|
||||
// internal layout invalidation) triggered another setState, which
|
||||
// re-rendered, which produced another `points` reference, which
|
||||
// fired the effect again — bounded only by React's depth limit.
|
||||
//
|
||||
// The fix: compute clusters in a layout-effect-free model. We
|
||||
// subscribe to Leaflet's moveend/zoomend (the only times cluster
|
||||
// pixel positions actually change) and re-cluster then. The initial
|
||||
// cluster pass happens after the map container's first layout
|
||||
// settles, gated by mapReadyRef so we never call latLngToContainer
|
||||
// on an unmeasured map. Content-dedup via signature string kills
|
||||
// redundant state churn even when Leaflet fires spurious moveend.
|
||||
function ClusterSync() {
|
||||
const map = useMap();
|
||||
|
||||
// Stable signature function — no deps so the ref never changes.
|
||||
const sigOf = useCallback((cs: Cluster[]) =>
|
||||
cs.map((c) => `${c.points.length}@${c.center[0].toFixed(4)},${c.center[1].toFixed(4)}`).join('|'),
|
||||
[]);
|
||||
|
||||
// Initial clustering: run once after map layout is ready. We use
|
||||
// a raf to wait for Leaflet's first container-size measurement,
|
||||
// because latLngToContainerPoint() returns invalid coords before
|
||||
// then — which would produce NaN-bearing clusters that flip the
|
||||
// sig every frame.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
mapReadyRef.current = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[ClusterSync] mount effect firing');
|
||||
const initialFrame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
mapReadyRef.current = true;
|
||||
const next = clusterPoints(points, map);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[ClusterSync] initial frame, sig=', sigOf(next));
|
||||
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
||||
});
|
||||
return () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[ClusterSync] mount cleanup');
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(initialFrame);
|
||||
};
|
||||
// We intentionally don't include `points` or `sigOf` here — this
|
||||
// is the FIRST snapshot only. Subsequent updates are driven by
|
||||
// the points-effect and the map event handlers below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [map]);
|
||||
|
||||
// Re-cluster when the points list itself changes (new items, tab
|
||||
// switch, PB poll). Content-deduped.
|
||||
useEffect(() => {
|
||||
if (!mapReadyRef.current) return;
|
||||
const next = clusterPoints(points, map);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[ClusterSync] points change, next.sig=', sigOf(next));
|
||||
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
||||
}, [points, map, sigOf]);
|
||||
|
||||
// Re-cluster on pan/zoom — these are the only events where cluster
|
||||
// pixel positions change. Read points from a ref so the handler
|
||||
// subscription is stable across renders.
|
||||
const pointsRef = useRef(points);
|
||||
useEffect(() => { pointsRef.current = points; }, [points]);
|
||||
useMapEvents({
|
||||
zoomend: () => {
|
||||
if (!mapReadyRef.current) return;
|
||||
const next = clusterPoints(pointsRef.current, map);
|
||||
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
||||
},
|
||||
moveend: () => {
|
||||
if (!mapReadyRef.current) return;
|
||||
const next = clusterPoints(pointsRef.current, map);
|
||||
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<MapContainer
|
||||
|
|
@ -143,7 +562,14 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<MapBounds items={items} />
|
||||
<MapBounds points={points} />
|
||||
<ClusterSync />
|
||||
<MapFloatingControls
|
||||
userLocation={userLocation}
|
||||
showTrail={showTrail}
|
||||
trailCount={trail.length}
|
||||
onToggleTrail={() => setShowTrail((v) => !v)}
|
||||
/>
|
||||
|
||||
{userLocation && (
|
||||
<Marker position={userLocation} icon={currentLocationIcon}>
|
||||
|
|
@ -179,93 +605,86 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{suppliers.map(({ supplier, items: supplierItems, coords }) => {
|
||||
const distance = getDistance(coords);
|
||||
{clusters.map((cluster, i) => {
|
||||
// Find the majority area color among the points in this visual cluster
|
||||
const colorCounts = new Map<number, number>();
|
||||
for (const p of cluster.points) {
|
||||
const ci = areaColorMap.get(p.item.id);
|
||||
if (ci !== undefined) {
|
||||
colorCounts.set(ci, (colorCounts.get(ci) || 0) + 1);
|
||||
}
|
||||
}
|
||||
let majorityColor: string | undefined;
|
||||
if (colorCounts.size > 0) {
|
||||
let maxCount = 0;
|
||||
let maxIdx = 0;
|
||||
Array.from(colorCounts.entries()).forEach(([idx, count]) => {
|
||||
if (count > maxCount) { maxCount = count; maxIdx = idx; }
|
||||
});
|
||||
majorityColor = AREA_COLORS[maxIdx];
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker key={supplier.id} position={[coords.latitude, coords.longitude]} icon={defaultIcon}>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] max-w-[260px]">
|
||||
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
||||
{supplier.supplieraddress && (
|
||||
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
|
||||
)}
|
||||
{distance && (
|
||||
<p className="text-xs text-[#f54e00] mb-2 font-semibold">
|
||||
{distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)} km`} away
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{supplierItems.map((i) => (
|
||||
<div key={i.id} className="text-xs p-2 bg-[#ebeae5] rounded-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{i.problem_flag && <AlertTriangle size={12} className="text-[#b3224a] flex-shrink-0" />}
|
||||
<span className="font-medium truncate">{i.description}</span>
|
||||
</div>
|
||||
<span className="text-[rgba(38,37,30,0.55)] text-[11px]">Qty: {i.order_qty || i.quantity || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-[#f54e00] text-white rounded-lg text-sm font-semibold active:bg-[#d44200] transition-colors"
|
||||
>
|
||||
<Navigation size={14} /> Navigate
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
<ClusterMarker
|
||||
key={`cm-${i}`}
|
||||
cluster={cluster}
|
||||
userLocation={userLocation}
|
||||
onNavigate={onNavigate}
|
||||
areaColor={majorityColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Floating controls */}
|
||||
<div className="absolute top-3 right-3 space-y-2 z-[400]">
|
||||
{/* Recenter */}
|
||||
{userLocation && (
|
||||
<RecenterButton center={userLocation} />
|
||||
)}
|
||||
|
||||
{/* Trail toggle */}
|
||||
{trail.length > 1 && (
|
||||
<button
|
||||
onClick={() => setShowTrail(!showTrail)}
|
||||
aria-pressed={showTrail}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-colors press-feedback-sm ${
|
||||
showTrail
|
||||
? 'bg-[#26251e] text-[#f2f1ed]'
|
||||
: 'bg-[#e6e5e0]/95 backdrop-blur-sm text-[#26251e]'
|
||||
}`}
|
||||
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
||||
>
|
||||
<Route size={14} />
|
||||
Trail
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl p-3"
|
||||
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider mb-2">Legend</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#f54e00] border-2 border-white shadow-sm" />
|
||||
<span className="text-[11px] text-[#26251e]">You</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3.5 bg-[#26251e]/70 rounded-sm" />
|
||||
<span className="text-[11px] text-[#26251e]">Stop</span>
|
||||
</div>
|
||||
{trail.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-0" style={{ borderTop: '2px dashed #1f8a65' }} />
|
||||
<span className="text-[11px] text-[#26251e]">Route</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Legend — pure UI, no leaflet hooks, safe to render outside MapContainer. */}
|
||||
<div
|
||||
className="absolute bottom-24 right-3 z-[400] bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl p-3"
|
||||
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider mb-2">Legend</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#f54e00] border-2 border-white shadow-sm" />
|
||||
<span className="text-[11px] text-[#26251e]">You</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Miniature of the actual dot + a tiny corner badge so the legend
|
||||
matches exactly what the map renders — not the old pill. */}
|
||||
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||
<div className="w-3.5 h-3.5 rounded-full bg-[#f54e00] border border-white shadow-sm" />
|
||||
<div className="absolute -top-0.5 -right-0.5 min-w-[12px] h-[12px] px-[3px] flex items-center justify-center rounded-full bg-[#26251e] text-[7px] font-bold text-[#f2f1ed] border border-white">
|
||||
N
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-[#26251e]">Waypoint (N stops)</span>
|
||||
</div>
|
||||
{trail.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-0" style={{ borderTop: '2px dashed #1f8a65' }} />
|
||||
<span className="text-[11px] text-[#26251e]">Route</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty-state overlay. Shown when the API returned items but none
|
||||
of them have usable supplier coordinates (the most common cause
|
||||
in this codebase: supplier row exists but coordinates field is
|
||||
blank). The map still renders, so the driver can manually
|
||||
navigate via the list view and search for the supplier. */}
|
||||
{points.length === 0 && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="absolute inset-x-0 bottom-24 mx-auto max-w-xs px-4 py-3 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl text-center z-[400] focus-within:ring-2 focus-within:ring-[#f54e00]"
|
||||
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-[#26251e]">No stops on the map yet</p>
|
||||
<p className="text-[11px] text-[rgba(38,37,30,0.55)] mt-1">
|
||||
Assigned pickups need a supplier address with coordinates. Use the list to add one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,61 +24,122 @@ export const login = async (email: string, password: string) => {
|
|||
return await pb.collection('users').authWithPassword(email, password);
|
||||
};
|
||||
|
||||
// Get assigned collections (order_items assigned to current driver).
|
||||
// The `order_items` schema has `supplier` (singular) and `driver`
|
||||
// (singular) as relation field names — those are the keys to use
|
||||
// in expand / sort / filter.
|
||||
export const getAssignedCollections = async () => {
|
||||
const user = getCurrentUser();
|
||||
if (!user) return [];
|
||||
// PB `order_items` schema (verified live 2026-07-07):
|
||||
// drv_id = relation -> users (canonical "assigned driver" field)
|
||||
// driver = legacy/parallel driver field (only set on 1 row)
|
||||
// supplier = relation -> suppliers (singular)
|
||||
// item_status real values: assigned, collection_pool, pending,
|
||||
// collected, problem, ...
|
||||
// drv_status real values: assigned, en_route, collected, failed, '' (empty),
|
||||
// unassigned
|
||||
// po_description = supplier-facing line ("M20×40 nut sku 11243…")
|
||||
// description = customer-facing line
|
||||
// order_qty = supplier-facing qty (falls back to quantity)
|
||||
// quantity = customer-facing qty
|
||||
// in_stock = NOT IN PB YET — add before relying on it
|
||||
// assigned_at = ISO timestamp, populated server-side on assignment
|
||||
// problem_notes = driver-written audit trail of failed collections
|
||||
// Unknown status values are NOT rejected by PB — they silently match
|
||||
// nothing. Always use the exact string below.
|
||||
|
||||
// 1) Happy path — rich expand + sort by supplier name.
|
||||
try {
|
||||
const records = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `driver = "${user.id}" && (item_status = "assigned" || item_status = "driver_pool")`,
|
||||
expand: 'supplier,order,purchase_order',
|
||||
sort: 'supplier.suppliername',
|
||||
});
|
||||
return records as unknown as OrderItem[];
|
||||
} catch (err: any) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console.warn('[pocketbase] expand=supplier/sort failed, retrying without it:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Resilient fallback — items only, then batched suppliers.
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `driver = "${user.id}" && (item_status = "assigned" || item_status = "driver_pool")`,
|
||||
});
|
||||
|
||||
const supplierIds = Array.from(new Set(
|
||||
// ---------- Hydration helper ----------
|
||||
// PB query builder can't always do the supplier expand (some deployments
|
||||
// strip expand support). This helper takes raw records and, if supplier
|
||||
// isn't already expanded, batch-fetches suppliers by id and stitches
|
||||
// them back into item.expand.supplier. Shared by every fetch below.
|
||||
const hydrateSuppliers = async (items: OrderItem[]): Promise<OrderItem[]> => {
|
||||
const missing = Array.from(new Set(
|
||||
items
|
||||
.map((it) => (it as any).supplier)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
.filter((it) => !it.expand?.supplier && (it as any).supplier)
|
||||
.map((it) => (it as any).supplier as string)
|
||||
));
|
||||
if (missing.length === 0) return items;
|
||||
|
||||
let supplierMap = new Map<string, any>();
|
||||
if (supplierIds.length > 0) {
|
||||
try {
|
||||
const filter = supplierIds.map((id) => `id = "${id}"`).join(' || ');
|
||||
const suppliers = await pb.collection('suppliers').getFullList({ filter });
|
||||
supplierMap = new Map(suppliers.map((s: any) => [s.id, s]));
|
||||
} catch {
|
||||
// Suppliers collection not reachable.
|
||||
}
|
||||
try {
|
||||
const filter = missing.map((id) => `id = "${id}"`).join(' || ');
|
||||
const suppliers = await pb.collection('suppliers').getFullList({ filter });
|
||||
supplierMap = new Map(suppliers.map((s: any) => [s.id, s]));
|
||||
} catch {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.map((it) => {
|
||||
if (it.expand?.supplier) return it;
|
||||
const supplierId = (it as any).supplier;
|
||||
const supplier = supplierId ? supplierMap.get(supplierId) : null;
|
||||
if (!supplier) return it;
|
||||
return {
|
||||
...it,
|
||||
expand: { ...(it.expand || {}), supplier },
|
||||
} as unknown as OrderItem;
|
||||
} as OrderItem;
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- 1. ASSIGNED — only items with drv_id = current user ----------
|
||||
// Filter uses `drv_id` (the canonical assigned-driver relation), not
|
||||
// `driver` (which is a legacy/parallel field only set on a handful of
|
||||
// rows). drv_id is the field per the project brief (2026-07-07).
|
||||
export const getMyAssignedItems = async (): Promise<OrderItem[]> => {
|
||||
const user = getCurrentUser();
|
||||
if (!user) return [];
|
||||
try {
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `drv_id = "${user.id}" && item_status = "assigned" && ready_for_collection = true`,
|
||||
expand: 'supplier,order,purchase_order',
|
||||
sort: 'supplier.suppliername',
|
||||
});
|
||||
return items;
|
||||
} catch {
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `drv_id = "${user.id}" && item_status = "assigned" && ready_for_collection = true`,
|
||||
});
|
||||
return hydrateSuppliers(items);
|
||||
}
|
||||
};
|
||||
|
||||
// Backwards-compat alias — keep old call sites working during the refactor.
|
||||
export const getAssignedCollections = getMyAssignedItems;
|
||||
|
||||
// ---------- 2. POOL — every collection_pool item in the system ----------
|
||||
// Pool means "open for any driver to claim". The driver app shows ALL
|
||||
// pool rows here so the driver can see what's available before claiming.
|
||||
// No driver filter on purpose.
|
||||
export const getPoolItems = async (): Promise<OrderItem[]> => {
|
||||
try {
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `item_status = "collection_pool" && ready_for_collection = true`,
|
||||
expand: 'supplier,order,purchase_order',
|
||||
sort: 'supplier.suppliername',
|
||||
});
|
||||
return items;
|
||||
} catch {
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `item_status = "collection_pool" && ready_for_collection = true`,
|
||||
});
|
||||
return hydrateSuppliers(items);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- 3. PROBLEMS — every flagged item in the system ----------
|
||||
// Items with problem_flag=true OR item_status='problem'. No driver
|
||||
// filter — problems are operational visibility, not personal lists.
|
||||
export const getProblemItems = async (): Promise<OrderItem[]> => {
|
||||
try {
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `problem_flag = true || item_status = "problem"`,
|
||||
expand: 'supplier,order,purchase_order',
|
||||
sort: '-updated',
|
||||
});
|
||||
return items;
|
||||
} catch {
|
||||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||||
filter: `problem_flag = true || item_status = "problem"`,
|
||||
});
|
||||
return hydrateSuppliers(items);
|
||||
}
|
||||
};
|
||||
|
||||
// Update order item status when collected
|
||||
export const updateCollectionStatus = async (
|
||||
id: string,
|
||||
|
|
@ -91,6 +152,43 @@ export const updateCollectionStatus = async (
|
|||
});
|
||||
};
|
||||
|
||||
// ---------- Display helpers ----------
|
||||
// Per the project brief:
|
||||
// po_description is the supplier-facing line (preferred on the PO
|
||||
// pickup screen); description is the customer-facing line. We always
|
||||
// show the supplier-facing line when it's there, and fall back to the
|
||||
// customer-facing one for legacy rows.
|
||||
export const getItemDisplayDescription = (item: OrderItem): string => {
|
||||
const po = (item.po_description || '').trim();
|
||||
if (po) return po;
|
||||
return (item.description || '').trim() || '—';
|
||||
};
|
||||
|
||||
// order_qty is the supplier-facing quantity (preferred on the pickup
|
||||
// screen); quantity is the customer-facing quantity. Per the brief:
|
||||
// order_qty "falls back to quantity if blank". We honor that order.
|
||||
export const getItemDisplayQty = (item: OrderItem): number => {
|
||||
const oq = item.order_qty;
|
||||
if (typeof oq === 'number' && !Number.isNaN(oq) && oq > 0) return oq;
|
||||
const q = item.quantity;
|
||||
if (typeof q === 'number' && !Number.isNaN(q) && q > 0) return q;
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ---------- Driver-side claim action ----------
|
||||
// When the driver sees an open item in Pool and wants to take it:
|
||||
// set drv_id = me, item_status = "assigned", assigned_at = now.
|
||||
export const claimItemForDriver = async (itemId: string) => {
|
||||
const user = getCurrentUser();
|
||||
if (!user) throw new Error('Not signed in');
|
||||
return await pb.collection('order_items').update(itemId, {
|
||||
drv_id: user.id,
|
||||
item_status: 'assigned',
|
||||
drv_status: 'assigned',
|
||||
assigned_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Report a problem on an order item
|
||||
export const reportProblem = async (
|
||||
orderItemId: string,
|
||||
|
|
|
|||
59
lib/types.ts
59
lib/types.ts
|
|
@ -25,30 +25,33 @@ export interface PurchaseOrder {
|
|||
export interface OrderItem {
|
||||
id: string;
|
||||
order: string;
|
||||
quote_item?: string;
|
||||
purchase_order?: string;
|
||||
supplier?: string;
|
||||
drv_id?: string;
|
||||
driver?: string;
|
||||
// Customer-facing line. Use this for label / list / customer view.
|
||||
description: string;
|
||||
// Supplier-facing line. Use this on the PO pickup screen — supplier
|
||||
// paperwork references this exact wording. Falls back to description.
|
||||
po_description?: string;
|
||||
notes?: string;
|
||||
problem_notes?: string;
|
||||
// Customer-side quantity. Nullable on legacy rows.
|
||||
quantity: number;
|
||||
// Supplier-side quantity. Per the brief, falls back to quantity when
|
||||
// blank (zero is treated as blank in getItemDisplayQty).
|
||||
order_qty?: number;
|
||||
qty_processed?: number;
|
||||
qty_assigned?: number;
|
||||
qty_collected?: number;
|
||||
qty_delivered?: number;
|
||||
// Pricing — operator-side (cost), customer-side (selling), and audit
|
||||
// source ('quote' | 'catalogue' | 'manual').
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
markup_percent?: number;
|
||||
selling_price?: number | null;
|
||||
pricing_source?: 'quote' | 'catalogue' | 'manual' | string;
|
||||
matched_quote_item?: string;
|
||||
// Supplier part number. Use this on PO paperwork over description.
|
||||
sku?: string;
|
||||
// Workflow states. item_status is the operator-side state, drv_status
|
||||
// is the driver-side state. They're independent enums — an item can
|
||||
// be item_status='collected' (operator marked it received) while
|
||||
// drv_status is still 'en_route' (driver hasn't checked in), etc.
|
||||
item_status:
|
||||
| 'pending'
|
||||
| 'processed'
|
||||
| 'po_generated'
|
||||
| 'driver_pool'
|
||||
| 'collection_pool'
|
||||
| 'assigned'
|
||||
| 'collected'
|
||||
| 'stock_verified'
|
||||
|
|
@ -56,15 +59,33 @@ export interface OrderItem {
|
|||
| 'ready_delivery'
|
||||
| 'delivered'
|
||||
| 'cancelled';
|
||||
drv_status?: 'unassigned' | 'assigned' | 'en_route' | 'collected' | 'failed';
|
||||
driver_status?: 'unassigned' | 'assigned' | 'en_route' | 'collected' | 'failed';
|
||||
drv_status?: 'unassigned' | 'assigned' | 'en_route' | 'collected' | 'failed' | string;
|
||||
driver_status?: string;
|
||||
delivery_status?: 'pending' | 'ready' | 'in_transit' | 'delivered' | 'failed';
|
||||
invoice_status?: 'pending' | 'invoiced' | 'paid' | 'cancelled';
|
||||
// Driver assignment. The canonical driver field per the brief is
|
||||
// `drv_id` (relation -> users); the older `driver` field is a legacy
|
||||
// shadow that only a handful of rows set. Use drv_id in every filter.
|
||||
drv_id?: string;
|
||||
driver?: string;
|
||||
// Server-side ISO timestamp of when drv_id was set. Surface in detail.
|
||||
assigned_at?: string;
|
||||
// Audit trail of failed-collection attempts. The driver writes here.
|
||||
// Distinct from notes (operator-side).
|
||||
notes?: string;
|
||||
problem_notes?: string;
|
||||
problem_flag?: boolean;
|
||||
problem_type?: 'stock' | 'supplier' | 'driver' | 'delivery' | 'pricing' | 'other';
|
||||
problem_type?: 'stock' | 'supplier' | 'driver' | 'delivery' | 'pricing' | 'other' | string;
|
||||
// Per-row quantities used for proving/dashboard math.
|
||||
qty_processed?: number;
|
||||
qty_assigned?: number;
|
||||
qty_collected?: number;
|
||||
qty_delivered?: number;
|
||||
drv_assigned_at?: string;
|
||||
drv_collected_at?: string;
|
||||
delivered_at?: string;
|
||||
markup?: number;
|
||||
markup_percent?: number;
|
||||
legacy_order_item_id?: string;
|
||||
// Backwards-compat shim the app uses in some spots
|
||||
is_stock_item?: boolean;
|
||||
|
|
@ -72,7 +93,7 @@ export interface OrderItem {
|
|||
collection_date?: string;
|
||||
// Expanded fields
|
||||
expand?: {
|
||||
supplier?: Supplier;
|
||||
supplier?: Supplier; // PB expand key matches relation field name
|
||||
order?: Order;
|
||||
purchase_order?: PurchaseOrder;
|
||||
};
|
||||
|
|
|
|||
80
lib/utils.ts
80
lib/utils.ts
|
|
@ -96,7 +96,6 @@ export function calculateDistance(
|
|||
export function parseCoordinates(coordString: string | undefined): { latitude: number; longitude: number } | null {
|
||||
if (!coordString) return null;
|
||||
|
||||
// Try to extract two numbers that look like lat/lng
|
||||
const matches = coordString.match(/-?\d+\.?\d*/g);
|
||||
if (matches && matches.length >= 2) {
|
||||
const lat = parseFloat(matches[0]);
|
||||
|
|
@ -108,3 +107,82 @@ export function parseCoordinates(coordString: string | undefined): { latitude: n
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ProximityCluster<T> {
|
||||
id: string;
|
||||
items: T[];
|
||||
center: { latitude: number; longitude: number };
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function clusterByProximity<T extends { id: string; expand?: { supplier?: { coordinates?: string; suppliername?: string } } }>(
|
||||
items: T[],
|
||||
radiusKm: number = 2
|
||||
): ProximityCluster<T>[] {
|
||||
const withCoords: Array<{ item: T; coords: { latitude: number; longitude: number } }> = [];
|
||||
const withoutCoords: T[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const coords = parseCoordinates(item.expand?.supplier?.coordinates);
|
||||
if (coords) {
|
||||
withCoords.push({ item, coords });
|
||||
} else {
|
||||
withoutCoords.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const assigned = new Set<string>();
|
||||
const clusters: ProximityCluster<T>[] = [];
|
||||
|
||||
for (const seed of withCoords) {
|
||||
if (assigned.has(seed.item.id)) continue;
|
||||
|
||||
const clusterItems: Array<{ item: T; coords: { latitude: number; longitude: number } }> = [seed];
|
||||
assigned.add(seed.item.id);
|
||||
|
||||
for (const candidate of withCoords) {
|
||||
if (assigned.has(candidate.item.id)) continue;
|
||||
const dist = calculateDistance(
|
||||
seed.coords.latitude, seed.coords.longitude,
|
||||
candidate.coords.latitude, candidate.coords.longitude
|
||||
);
|
||||
if (dist <= radiusKm) {
|
||||
clusterItems.push(candidate);
|
||||
assigned.add(candidate.item.id);
|
||||
}
|
||||
}
|
||||
|
||||
const centerLat = clusterItems.reduce((s, p) => s + p.coords.latitude, 0) / clusterItems.length;
|
||||
const centerLng = clusterItems.reduce((s, p) => s + p.coords.longitude, 0) / clusterItems.length;
|
||||
|
||||
const supplierNames = Array.from(
|
||||
new Set(
|
||||
clusterItems
|
||||
.map(c => c.item.expand?.supplier?.suppliername)
|
||||
.filter((n): n is string => !!n)
|
||||
)
|
||||
);
|
||||
|
||||
clusters.push({
|
||||
id: `cluster-${clusters.length}`,
|
||||
items: clusterItems.map(c => c.item),
|
||||
center: { latitude: centerLat, longitude: centerLng },
|
||||
label: supplierNames.length === 1
|
||||
? supplierNames[0]
|
||||
: supplierNames.length > 1
|
||||
? `${supplierNames[0]} +${supplierNames.length - 1}`
|
||||
: `Stop ${clusters.length + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (withoutCoords.length > 0) {
|
||||
clusters.push({
|
||||
id: 'cluster-no-coords',
|
||||
items: withoutCoords,
|
||||
center: { latitude: 0, longitude: 0 },
|
||||
label: 'No location',
|
||||
});
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue