`,
iconSize: [30, 30],
iconAnchor: [15, 15],
});
// Numbered stop pin in the rider's colour. Pickups get a square pin, deliveries a
// round one — so the two journey types are distinguishable at a glance on the map.
const stopIcon = (n, color, focused, isPickup) => new L.DivIcon({
className: 'rr-icon',
html: `
${n}
`,
iconSize: focused ? [30, 30] : [24, 24],
iconAnchor: focused ? [15, 15] : [12, 12],
});
// The animated "moving rider" puck in the rider's colour.
const moverIcon = (color) => new L.DivIcon({
className: 'rr-icon rr-mover',
html: `
`,
iconSize: [30, 30],
iconAnchor: [15, 15],
});
// ════════════════════════════════════════════════════════════════════════════════
// Rider routes — loaded live from GET /hub/rider-routes (see the load effect below).
// Each rider has a PICKUP trip for the day with its own ordered stops and distance;
// every order stop carries the detail shown in the side drawer when clicked.
//
// Pickup: merchant collections → hub.
// ════════════════════════════════════════════════════════════════════════════════
// The hub all pickup legs return to. Label comes from the logged-in hub context;
// the coordinate is only the initial map centre (the map auto-fits to the stops).
const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' };
// Palette assigned to milers round-robin so each route line is a distinct colour.
const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
// Map an API rider-route (from GET /hub/rider-routes) into the structure this
// page renders: a `pickup` trip with a list of order stops. Fields the API does
// not provide (customer, weight, COD, slot, instructions) default gracefully.
const mapRoute = (r, i) => {
// Be tolerant of backend field-name variants: the stops list, coordinates, ids
// and distance can arrive under a few different keys. `??` only falls back when
// the primary key is missing, so the documented shape still works unchanged.
const apiStops = [r.stops, r.route, r.pickups, r.orders, r.legs].find(Array.isArray) || [];
const mileruserid = r.mileruserid ?? r.userid ?? r.miler_user_id ?? r.id;
const stops = apiStops.map((s) => {
const bookingid = s.bookingid ?? s.consignmentid ?? s.booking_id;
return {
kind: 'order',
orderId: bookingid != null ? `BK-${bookingid}` : `Stop ${s.seq ?? ''}`,
bookingid,
customer: s.customer || s.sendername || s.name || '',
phone: s.phone || '',
address: s.address || '—',
lat: s.lat ?? s.latitude,
lng: s.lon ?? s.lng ?? s.longitude,
time: s.eta_minutes != null ? `${s.eta_minutes} min` : '',
status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending',
// Optional fields: keep whatever the API sends; `null`/'' means "unknown" and
// the UI hides it rather than showing a fake 0. No placeholder/mock values.
items: s.items ?? s.parcels ?? null,
weight: s.weight ?? '',
// Backend (buildMilerRoute) emits `timeslot` and `legdistance_km`; keep the
// other aliases as fallbacks in case the shape ever changes.
slot: s.timeslot ?? s.slot ?? s.time_slot ?? '',
cod: s.cod ?? s.cod_amount ?? 0,
payment: s.payment ?? s.payment_mode ?? '',
legKm: s.legdistance_km ?? s.legKm ?? s.leg_km ?? s.distance_km ?? null,
instructions: s.instructions ?? s.notes ?? ''
};
});
return {
id: `RDR-${mileruserid}`,
mileruserid,
name: r.milername || r.displayname || r.name || `Miler ${mileruserid}`,
color: ROUTE_COLORS[i % ROUTE_COLORS.length],
vehicle: '—',
vehicleNo: '—',
phone: '',
pickup: {
startTime: '',
endTime: '',
distanceKm: r.totaldistance_km ?? r.total_distance_km ?? r.distance_km ?? r.distance ?? 0,
stops
}
};
};
// ── helpers ────────────────────────────────────────────────────────────────────
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
const inr = (n) => `₹${(Number(n) || 0).toLocaleString('en-IN')}`;
const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
const STATUS_META = {
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' },
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' },
'In progress': { color: '#F29900', icon: DeliveryDiningRoundedIcon, label: 'In progress' },
Pending: { color: '#80868B', icon: ScheduleOutlinedIcon, label: 'Pending' },
};
const MODES = {
pickup: { label: 'Pickups', icon: StorefrontOutlinedIcon, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' },
};
// ════════════════════════════════════════════════════════════════════════════════
// Route resolution — OSRM road geometry (free, no API key); straight-line fallback.
// ════════════════════════════════════════════════════════════════════════════════
async function fetchRoadRoute(stops) {
const coords = stops.map((s) => `${s.lng},${s.lat}`).join(';');
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`;
const res = await fetch(url);
const json = await res.json();
if (json.routes && json.routes[0]) {
return json.routes[0].geometry.coordinates.map(([lng, lat]) => ({ lat, lng }));
}
throw new Error('No route');
}
// ════════════════════════════════════════════════════════════════════════════════
// Small presentational pieces
// ════════════════════════════════════════════════════════════════════════════════
// KPI card — aliased to the shared StatCard so this strip matches every other page.
const KpiCard = (props) => ;
function DetailRow({ icon: Icon, label, value, valueColor }) {
if (value === undefined || value === null || value === '') return null;
return (
{label}{value}
);
}
// Full order/pickup detail panel — rendered INLINE in the left column (not a modal)
// so the map stays visible and zoomed to the stop while these details are read.
function OrderDetailPanel({ data, onBack }) {
const { order, rider, mode, index } = data;
const meta = STATUS_META[order.status] || {};
const StatusIcon = meta.icon || CheckCircleRoundedIcon;
const isPickup = mode === 'pickup';
return (
{/* Header */}
} size="small"
sx={{ color: 'rgba(255,255,255,0.95)', textTransform: 'none', fontWeight: 600, mb: 1.25, ml: -0.5, '&:hover': { bgcolor: 'rgba(255,255,255,0.12)' } }}>
Close
: )} label={isPickup ? 'Pickup' : 'Delivery'}
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700, '& .MuiChip-icon': { color: '#fff' } }} />
} label={meta.label || order.status}
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
{order.customer}#{order.orderId}
{/* Body */}
{/* Contact */}
{isPickup ? 'MERCHANT / SENDER' : 'CUSTOMER'}
{/* Pickup summary */}
PICKUP SUMMARY
{order.cod > 0 && (
CASH ON PICKUP{inr(order.cod)}
)}
{/* Route & location */}
ROUTE & LOCATION
{order.instructions && }
{/* Assigned miler */}
ASSIGNED MILER
{initials(rider.name)}{rider.name}{rider.vehicle} · {rider.vehicleNo} · Stop {index}
{/* Footer */}
} href={`tel:${order.phone}`} sx={{ borderRadius: 2 }}>Call
} onClick={onBack} sx={{ borderRadius: 2, bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }}>Done
);
}
// ════════════════════════════════════════════════════════════════════════════════
// MAIN COMPONENT
// ════════════════════════════════════════════════════════════════════════════════
export default function RiderRoutes() {
const [mode] = useState('pickup'); // pickups only
const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes
const [loading, setLoading] = useState(true);
const [visible, setVisible] = useState({});
const [routes, setRoutes] = useState({}); // keyed `${id}__${mode}`
const [expanded, setExpanded] = useState(null);
const [focusedStop, setFocusedStop] = useState(null); // `${riderId}-${index}`
const [detail, setDetail] = useState(null); // { order, rider, mode, index }
const [flyTarget, setFlyTarget] = useState(null);
// Animation state.
const [playing, setPlaying] = useState(null);
const [progress, setProgress] = useState(0);
const [speed, setSpeed] = useState(1);
const [speedAnchor, setSpeedAnchor] = useState(null);
const rafRef = useRef(null);
const lastTsRef = useRef(0);
const resolvedRef = useRef({}); // keys we've already fetched/attempted
const mapRef = useRef(null); // map column — scrolled into view when playback starts on mobile
const modeCfg = MODES[mode];
// Load today's rider routes for this hub.
useEffect(() => {
let cancelled = false;
getRiderRoutes()
.then((res) => {
if (cancelled) return;
const mapped = (res?.data || []).map(mapRoute);
setRiders(mapped);
setVisible(Object.fromEntries(mapped.map((r) => [r.id, true])));
setExpanded(mapped[0]?.id ?? null);
})
.catch(() => !cancelled && setRiders([]))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, []);
// Resolve road routes for the active mode (cached per id+mode). A ref guards
// against re-fetching keys we've already resolved when the tab is revisited.
useEffect(() => {
let cancelled = false;
(async () => {
await Promise.all(
riders.map(async (r) => {
const key = `${r.id}__${mode}`;
if (resolvedRef.current[key]) return;
resolvedRef.current[key] = true;
const stops = r[mode].stops;
let path;
try { path = await fetchRoadRoute(stops); }
catch { path = stops.map((s) => ({ lat: s.lat, lng: s.lng })); }
if (!cancelled) setRoutes((prev) => ({ ...prev, [key]: path }));
})
);
})();
return () => { cancelled = true; };
}, [mode, riders]);
const pathFor = useCallback(
(r) => routes[`${r.id}__${mode}`] || r[mode].stops.map((s) => ({ lat: s.lat, lng: s.lng })),
[routes, mode]
);
const stopsOf = useCallback((r) => r[mode].stops, [mode]);
// Auto-fit points = visible riders' stops for the active mode.
const fitPoints = useMemo(() => {
const pts = [];
riders.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); });
return pts;
}, [riders, visible, stopsOf]);
// ── Analysis KPIs for the active mode ─────────────────────────────────────────
const kpi = useMemo(() => {
let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0;
riders.forEach((r) => {
const trip = r[mode];
const orderStops = trip.stops.filter((s) => s.kind === 'order');
if (orderStops.length) activeRiders += 1;
orders += orderStops.length;
done += orderStops.filter((s) => s.status === modeCfg.doneStatus).length;
fail += orderStops.filter((s) => s.status === modeCfg.failStatus).length;
km += trip.distanceKm;
cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0);
});
return { orders, done, fail, km: km.toFixed(1), cod, activeRiders };
}, [riders, mode, modeCfg]);
// ── Animation driver ──────────────────────────────────────────────────────────
useEffect(() => {
if (!playing) { if (rafRef.current) cancelAnimationFrame(rafRef.current); return; }
lastTsRef.current = 0;
const DURATION = 14000;
const tick = (ts) => {
if (!lastTsRef.current) lastTsRef.current = ts;
const dt = ts - lastTsRef.current;
lastTsRef.current = ts;
setProgress((p) => {
const nextP = p + (dt * speed) / DURATION;
if (nextP >= 1) { setPlaying(null); return 1; }
return nextP;
});
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [playing, speed]);
// Stop any animation when switching tabs.
useEffect(() => { setPlaying(null); setProgress(0); }, [mode]);
const startAnim = (riderId) => {
setVisible((v) => ({ ...v, [riderId]: true }));
if (playing === riderId) { setPlaying(null); return; }
if (playing !== riderId) setProgress(0);
setPlaying(riderId);
// On the stacked layout (< lg) the map sits below the list, so the moving
// marker would animate off-screen. Bring the map into view when playback starts.
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1199px)').matches) {
requestAnimationFrame(() => mapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}
};
const resetAnim = () => { setPlaying(null); setProgress(0); };
const toggleVisible = (id) => setVisible((v) => ({ ...v, [id]: !v[id] }));
const openDetail = (rider, order, index) => {
setDetail({ order, rider, mode, index });
setExpanded(rider.id);
setFocusedStop(`${rider.id}-${index}`);
setFlyTarget({ lat: order.lat, lng: order.lng });
};
const closeDetail = () => { setDetail(null); setFlyTarget(null); setFocusedStop(null); };
const playState = useMemo(() => {
if (!playing) return null;
const rider = riders.find((r) => r.id === playing);
if (!rider) return null;
const path = pathFor(rider);
if (path.length < 2) return null;
const totalSegs = path.length - 1;
const exact = progress * totalSegs;
const i = Math.min(Math.floor(exact), totalSegs - 1);
const frac = exact - i;
const pos = lerpPoint(path[i], path[i + 1], frac);
const travelled = [...path.slice(0, i + 1), pos];
return { rider, path, travelled, pos };
}, [playing, progress, pathFor, riders]);
return (
{/* ── Header ── */}
Rider Routes
Pickups each miler covered today open any order for full details, or press play to replay the trip.
{!loading && riders.length === 0 && (
No rider routes today
Once milers are assigned pickups, their planned stops will show up here.
)}
{/* ── Analysis KPI strip ── */}
{[
{ icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${riders.length}`, color: '#1A73E8', bg: '#E8F0FE' },
{ icon: modeCfg.icon, label: modeCfg.label, value: kpi.orders, sub: 'pickups covered', color: '#C01227', bg: alpha('#C01227', 0.1) },
{ icon: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' },
{ icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
].map((k, i) => (
))}
{/* ── Left control panel — the milers list; clicking an order opens the
full detail in a right-side drawer (below). ── */}
Milers & their {modeCfg.label.toLowerCase()}
{(playing || progress > 0) && (
} onClick={resetAnim} sx={{ textTransform: 'none', color: '#5F6368' }}>Reset
)}
{riders.map((rider) => {
const trip = rider[mode];
const orders = trip.stops.filter((s) => s.kind === 'order');
const done = orders.filter((o) => o.status === modeCfg.doneStatus).length;
const isOpen = expanded === rider.id;
const isPlaying = playing === rider.id;
const isVisible = visible[rider.id];
return (
{initials(rider.name)} setExpanded(isOpen ? null : rider.id)} sx={{ flex: 1, borderRadius: 2, px: 1, py: 0.5, minWidth: 0 }}>
{rider.name}}
secondary={{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km}
/>
{isOpen ? : }
{/* per-rider stat chips (analysis) */}
} label={`${orders.length} ${modeCfg.label.toLowerCase()}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
} label={done} sx={{ height: 24, fontWeight: 600, bgcolor: alpha('#1E8E3E', 0.1), color: '#1E8E3E' }} />
} label={`${trip.distanceKm} km`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
} label={`${trip.startTime}–${trip.endTime}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
{/* action row */}
: }
onClick={() => startAnim(rider.id)}
sx={{
textTransform: 'none', fontWeight: 600, borderRadius: 2, flex: 1,
...(isPlaying
? { bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }
: { color: rider.color, borderColor: alpha(rider.color, 0.5), '&:hover': { borderColor: rider.color, bgcolor: alpha(rider.color, 0.06) } }),
}}
>
{isPlaying ? 'Playing…' : 'Animate route'}
toggleVisible(rider.id)} sx={{ border: '1px solid #E9ECEF', borderRadius: 2 }}>
{isVisible ? : }
{isPlaying && (
)}
{/* expandable stops — rich order cards; click to open detail */}
{trip.stops.map((s, i) => {
const key = `${rider.id}-${i}`;
const isHub = s.kind === 'hub';
const meta = STATUS_META[s.status];
const StatusIcon = meta?.icon;
// Hub return — compact row, not a card.
if (isHub) {
return (
{s.label}Back to hub · {s.time}
);
}
return (
setFocusedStop(key)}
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
onClick={() => openDetail(rider, s, i)}
sx={{
p: 1.5, borderRadius: 2, cursor: 'pointer', transition: 'all .15s',
border: '1px solid', borderColor: focusedStop === key ? alpha(rider.color, 0.55) : '#ECEEF1',
bgcolor: focusedStop === key ? alpha(rider.color, 0.04) : '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.03)',
'&:hover': { borderColor: alpha(rider.color, 0.55), boxShadow: '0 4px 14px rgba(0,0,0,0.07)' },
}}
>
{/* order id + status */}
{i}Order #{s.orderId}
{meta && (
} label={meta.label}
sx={{ height: 22, fontSize: '0.68rem', fontWeight: 700, color: meta.color, bgcolor: alpha(meta.color, 0.1), flexShrink: 0 }} />
)}
{/* rider + time */}
{rider.name}{s.time}
{/* merchant + address */}
{s.customer}{s.address}
{/* metric chips — only render fields the API actually returned */}
{(s.legKm != null || s.weight || s.items != null) && (
{s.legKm != null && } label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
{s.weight && } label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
{s.items != null && } label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
)}
);
})}
);
})}
{/* ── Map ── */}
{playState && (
{initials(playState.rider.name)}{playState.rider.name}Replaying {modeCfg.label.toLowerCase().replace(/s$/, '')} · {Math.round(progress * 100)}% setPlaying(null)}> setSpeedAnchor(e.currentTarget)}>
)}
{!playing && !flyTarget && }
{HUB.label} Pickups return here
{riders.map((rider) => {
if (!visible[rider.id]) return null;
const path = pathFor(rider);
const stops = stopsOf(rider);
const dimmed = playing && playing !== rider.id;
const endStop = stops[stops.length - 1];
return (
[p.lat, p.lng])} pathOptions={{ color: rider.color, weight: 5, opacity: dimmed ? 0.15 : 0.85, dashArray: modeCfg.dash }} />
{stops.map((s, i) => {
if (s.kind === 'hub' || s.lat == null || s.lng == null) return null;
const key = `${rider.id}-${i}`;
return (
openDetail(rider, s, i) }}
>
{modeCfg.pointLabel} {i} · {s.customer}{s.address}#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label} openDetail(rider, s, i)}>View full details →
);
})}
{!dimmed && endStop && (
{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}
)}
);
})}
{playState && (
<>
[p.lat, p.lng])} pathOptions={{ color: playState.rider.color, weight: 7, opacity: 1 }} />
{playState.rider.name}
>
)}
{/* Legend */}
{riders.map((r) => (
toggleVisible(r.id)}>
{r.name}
))}
{mode === 'pickup' ? 'Hub return' : 'Trip end'}Hub
{/* ── Order detail — right-side drawer ── */}
t.zIndex.modal,
'& .MuiDrawer-paper': {
width: { xs: '100%', sm: 360 },
maxWidth: '100%',
top: { xs: 0, sm: 64 },
height: { xs: '100%', sm: 'calc(100% - 64px)' },
borderTopLeftRadius: { sm: 16 },
overflow: 'hidden',
boxShadow: '-8px 0 30px rgba(0,0,0,0.12)',
},
}}
>
{detail && }
);
}