updates on the dispatch active page and the navbar design

This commit is contained in:
2026-06-08 20:21:36 +05:30
parent bbec0aa910
commit fd27ac92d8
8 changed files with 1261 additions and 434 deletions

View File

@@ -21,6 +21,7 @@ import {
MdLocationOn,
MdMarkunreadMailbox,
MdMoveToInbox,
MdPerson,
MdPlace,
MdTwoWheeler,
MdNotes,
@@ -478,7 +479,7 @@ const getStableRiderColor = (id) => {
// extracted CompareDataPanel component can import them without forcing
// a circular dependency on Dispatch.js.
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey, extraPoints }) => {
const map = useMap();
// Last fit signature. We only call fitBounds when this changes — otherwise
// every parent render (data refetch, sidebar tick, etc.) would refit the
@@ -529,7 +530,11 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
}
if (viewMode === 'all') {
const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`;
// Include active riders' live GPS so the fit reframes when an order-less
// (GPS-only) rider appears/moves and there are no drops to anchor to.
const ePairs = extraPoints || [];
const allPairs = oPairs.concat(ePairs);
return `${loc}a|${allPairs.length}|${centroidSig(allPairs)}`;
}
return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`;
}, [focusedItem, viewMode, orders, kitchens, locationKey]);
@@ -539,10 +544,12 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
let pts = [];
if (focusedItem) {
if (focusedItem.orders) {
if (focusedItem.orders && focusedItem.orders.length) {
pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))]));
} else {
// Order-less focus target (a single kitchen, or a GPS-only active
// rider) — center on its own coordinate.
pts = [[focusedItem.lat, focusedItem.lon]];
}
} else if (viewMode === 'kitchens') {
@@ -554,6 +561,9 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
}
} else if (viewMode === 'all') {
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
// Frame order-less active riders (GPS-only) too — their live positions
// are the only thing to show for them.
pts = pts.concat(extraPoints || []);
} else {
// No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the
// current hub's footprint so switching from Coimbatore → Nagercoil
@@ -593,11 +603,130 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
// bug that left Nagercoil (and every non-Coimbatore hub) stuck on the
// Coimbatore default during the brief window between picking the hub
// and its data arriving.
}, [fitKey, focusedItem, viewMode, orders, kitchens, map]);
}, [fitKey, focusedItem, viewMode, orders, kitchens, extraPoints, map]);
return null;
};
// Smoothly-moving rider marker — the Swiggy/Zomato/Rapido style "bike gliding
// down the road" effect. Instead of letting react-leaflet snap the marker to
// each new GPS fix, we keep the <Marker>'s `position` prop frozen at its mount
// coordinate (a stable ref, so react-leaflet never repositions it) and drive
// every subsequent move imperatively with marker.setLatLng() inside a
// requestAnimationFrame loop. Each time `target` changes we ease from the
// marker's current on-screen latlng to the new fix, so the rider visibly
// travels between points rather than teleporting. A large jump (GPS glitch /
// first real fix after a placeholder) snaps instead of crawling across the map.
//
// ADAPTIVE GLIDE — the critical bit for "live" feel:
// We poll the GPS feed every 1s, but the backend only emits a fresh coordinate
// every ~30s, so the same fix repeats for ~30 polls and then jumps. With a
// fixed glide we'd animate for ~1s and then sit frozen for ~29s — the bike
// would look like it teleports every 30s. Instead we MEASURE the real wall-time
// between distinct fixes and stretch the glide across that whole interval
// (clamped). So when fixes are 30s apart the bike eases continuously for the
// full 30s; if the backend ever speeds up to 1s the glide tightens to 1s
// automatically. Either way motion is smooth and never freezes mid-trip.
// `duration` is only the seed used for the very first segment (no prior fix to
// measure against yet).
const MIN_GLIDE_MS = 800; // floor: don't animate faster than this even on rapid fixes
const MAX_GLIDE_MS = 32_000; // ceiling: cover the ~30s backend cadence + small buffer
const AnimatedRiderMarker = ({ target, icon, duration = 950, zIndexOffset, eventHandlers, children, markerRef: externalRef }) => {
const markerRef = useRef(null);
const rafRef = useRef(null);
// Frozen mount position — never handed back to react-leaflet again, so it
// can't fight the imperative animation below.
const mountPosRef = useRef(target);
// performance.now() timestamp of the last DISTINCT fix we glided to. Lets us
// measure the true inter-fix interval and size the next glide to match it.
const prevFixTsRef = useRef(null);
// CRITICAL: depend on the primitive lat/lon, NOT the `target` array. The
// parent re-renders every second (clock tick + 1s GPS poll) and hands us a
// brand-new `[lat, lon]` array each time. If the effect keyed off that array
// it would cancel + restart the glide on every render — the ease keeps
// resetting to zero velocity and the bike visibly stutters/lags. Keying off
// the numbers means the glide only (re)starts when the rider's coordinate
// genuinely changes, so each 1s segment plays out uninterrupted.
const lat = Array.isArray(target) ? Number(target[0]) : NaN;
const lon = Array.isArray(target) ? Number(target[1]) : NaN;
useEffect(() => {
const marker = markerRef.current;
if (!marker || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
const to = L.latLng(lat, lon);
const from = marker.getLatLng();
if (!from) {
marker.setLatLng(to);
return undefined;
}
const dLat = to.lat - from.lat;
const dLng = to.lng - from.lng;
// No meaningful move (~<0.1m) — snap and skip the rAF.
if (Math.abs(dLat) < 1e-6 && Math.abs(dLng) < 1e-6) {
marker.setLatLng(to);
return undefined;
}
// Teleport on big jumps (>2km) so a bad fix doesn't drag the icon across town.
let bigJump = false;
try {
bigJump = from.distanceTo(to) > 2000;
} catch {
bigJump = false;
}
if (bigJump) {
marker.setLatLng(to);
prevFixTsRef.current = performance.now();
return undefined;
}
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const startTs = performance.now();
// Size this glide to the real gap since the previous fix so the bike keeps
// moving for the whole interval instead of darting then freezing. First
// segment has no prior timestamp, so fall back to the `duration` seed.
const gap = prevFixTsRef.current == null ? duration : startTs - prevFixTsRef.current;
const segMs = Math.max(MIN_GLIDE_MS, Math.min(MAX_GLIDE_MS, gap));
prevFixTsRef.current = startTs;
const startLat = from.lat;
const startLng = from.lng;
const step = (now) => {
const t = Math.min(1, (now - startTs) / segMs);
// Linear interpolation → constant speed. Spanning the glide across the
// full inter-fix interval chains consecutive segments into one continuous
// motion (a vehicle moving down the road) rather than the accelerate/brake
// feel an easing curve gives, or the dart-then-freeze of a fixed duration.
marker.setLatLng([startLat + dLat * t, startLng + dLng * t]);
if (t < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [lat, lon, duration]);
// Cleanup any in-flight animation on unmount.
useEffect(() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
}, []);
return (
<Marker
ref={(inst) => {
markerRef.current = inst;
if (typeof externalRef === 'function') externalRef(inst);
else if (externalRef) externalRef.current = inst;
}}
position={mountPosRef.current}
icon={icon}
zIndexOffset={zIndexOffset}
eventHandlers={eventHandlers}
>
{children}
</Marker>
);
};
// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the
// SVG vertically centered with the adjacent text and inherits the parent color.
const Ico = ({ children }) => (
@@ -699,6 +828,12 @@ const Dispatch = ({
const [focusedZone, setFocusedZone] = useState(null);
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
const [focusedStop, setFocusedStop] = useState(null);
// How the stops inside each trip block are ordered in the focused-rider sidebar:
// 'planned' → the dispatched route order (by step) — the default.
// 'time' → re-sorted by when each delivery was actually completed, so the
// operator can see which drop happened first regardless of the
// planned sequence. Undelivered stops sink to the bottom.
const [tripSortMode, setTripSortMode] = useState('planned');
// Holds leaflet marker instances keyed by orderid so we can imperatively open
// their popups when the user clicks a step in the focused-rider sidebar.
const orderMarkerRefs = useRef({});
@@ -712,6 +847,13 @@ const Dispatch = ({
// popups use leaflet's marker-attached <Popup> (openPopup/closePopup) rather
// than the centered overlay used for order popups.
const pinnedLivePopupsRef = useRef(new Set());
// Per-rider cache of the live bike L.divIcon, keyed by id. The page re-renders
// every second (clock tick + 1s GPS poll in the active view); without this
// cache we'd hand react-leaflet a brand-new icon object each tick, forcing a
// setIcon() that wipes the marker DOM and restarts the CSS pulse animation.
// Caching by a content signature keeps the icon reference stable when nothing
// changed, so the pulse animates smoothly and only the position eases.
const liveIconCacheRef = useRef(new Map());
// Short-lived close timer for the general map order/marker popups.
// Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close.
@@ -1074,12 +1216,18 @@ const Dispatch = ({
// hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page.
// Poll cadence: in the "All Active Routes" view the operator is watching
// riders physically move, so we refresh the GPS feed every second (Swiggy /
// Rapido style live tracking). The AnimatedRiderMarker eases the bike between
// fixes so motion stays smooth even if a fix is unchanged. Other views don't
// need second-by-second churn, so they stay on the lighter 15s cadence.
const RIDER_LOG_POLL_MS = viewMode === 'all' ? 1_000 : 15_000;
const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''],
queryFn: fetchRidersLogs,
refetchInterval: 15_000,
refetchInterval: RIDER_LOG_POLL_MS,
refetchIntervalInBackground: false,
staleTime: 5 * 1000,
staleTime: 1000,
refetchOnWindowFocus: false
});
@@ -1105,6 +1253,15 @@ const Dispatch = ({
})
.filter(Boolean);
}, [ridersLocationLogs]);
// Set of rider ids whose latest GPS log row is `active` (i.e. on the road
// right now). The "All Active Routes" view (viewMode === 'all') uses this to
// show ONLY currently-active riders — their cards, routes, drop markers and
// live bike markers — and hide everyone who is offline/idle for the slot.
const activeRiderIdSet = useMemo(
() => new Set(liveRiderLocations.filter((r) => r.status === 'active').map((r) => String(r.id))),
[liveRiderLocations]
);
// Default to the slot containing the current wall-clock time. Use a
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
// the current time falls outside every slot window (e.g. before 8 AM)
@@ -1395,6 +1552,60 @@ const Dispatch = ({
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: internalFocusedRider;
// "All Active Routes" view scoping. In this mode we render only riders whose
// live GPS log is `active` right now — their cards, routes, drop markers and
// bike markers — so the operator sees the on-road fleet at a glance. Every
// other view (By Location / By Zone / By Rider) is unchanged.
const isAllActiveView = viewMode === 'all';
// Active riders the GPS feed reports as on-road RIGHT NOW that have no orders
// in the current data (i.e. nothing to deliver). We still want them on screen
// in "All Active Routes" — just their live bike marker, no route — so we
// synthesize order-less rider objects shaped like real ones. `gpsOnly` flags
// them so the card / route code treats them as "live position only".
const gpsOnlyActiveRiders = useMemo(() => {
if (!isAllActiveView) return [];
const haveOrders = new Set(riders.map((r) => String(r.id)));
return liveRiderLocations
.filter((r) => r.status === 'active' && !haveOrders.has(String(r.id)))
.map((r) => ({
id: r.id,
riderName: r.username || `Rider #${r.id}`,
orders: [],
color: getStableRiderColor(r.id),
gpsOnly: true,
// Live position — lets MapController center on the rider when their
// GPS-only card is clicked (they have no drops to fit to).
lat: r.lat,
lon: r.lon
}));
}, [isAllActiveView, liveRiderLocations, riders]);
// The riders we render in "All Active Routes": every rider whose live GPS is
// active — those with orders (real route shown) PLUS those without (GPS only).
// Every other view (By Location / By Zone / By Rider) is unchanged.
const visibleRiders = useMemo(
() =>
isAllActiveView
? [...riders.filter((r) => activeRiderIdSet.has(String(r.id))), ...gpsOnlyActiveRiders]
: riders,
[isAllActiveView, riders, activeRiderIdSet, gpsOnlyActiveRiders]
);
// Orders that belong to the riders we're actually showing — drives the drop
// markers and the map auto-fit bounds in the active view.
const allViewOrders = useMemo(
() => (isAllActiveView ? allOrders.filter((o) => activeRiderIdSet.has(String(o.rider_id))) : allOrders),
[isAllActiveView, allOrders, activeRiderIdSet]
);
// Live GPS coordinates of every active rider in the "All Active Routes" view.
// Fed to MapController's auto-fit so order-less (GPS-only) active riders are
// framed even when there are no drop markers to anchor the bounds.
const allViewLivePoints = useMemo(
() =>
isAllActiveView
? liveRiderLocations.filter((r) => r.status === 'active').map((r) => [r.lat, r.lon])
: [],
[isAllActiveView, liveRiderLocations]
);
// Per-rider canvas renderer for the actual (right) map in Compare mode.
// Single setter used by every interactive site in the UI. In uncontrolled mode it
// updates local state; in controlled mode it only notifies the parent.
@@ -1473,6 +1684,19 @@ const Dispatch = ({
label: 'Focused Kitchen'
};
}
// "All Active Routes": the header must reflect exactly what the list/map
// shows — the active fleet (riders with orders + GPS-only riders) and their
// orders — NOT the whole day's totals. Otherwise the top "Riders" tile (full
// fleet) disagrees with the rider list below (active-only).
if (isAllActiveView) {
return {
orders: allViewOrders.length,
riders: visibleRiders.length,
km: allViewOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
profit: allViewOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
label: 'Active Fleet'
};
}
return {
orders: stats.totalOrders,
riders: stats.totalRiders,
@@ -1480,7 +1704,7 @@ const Dispatch = ({
profit: stats.totalProfit,
label: 'Total Fleet'
};
}, [focusedRider, focusedKitchen, stats]);
}, [focusedRider, focusedKitchen, isAllActiveView, allViewOrders, visibleRiders, stats]);
// List of deliveryids we want GPS logs for. Drives two pipelines:
// • renderRoutes() — actual-route polylines on the main map for
@@ -2225,35 +2449,52 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null;
// GPS-only rider: live on the road but no orders in this slot. Show a card
// that makes the "tracking position only, no active delivery" state obvious.
const isGpsOnly = r.gpsOnly || total === 0;
return (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div key={r.id} className={`rcard${isGpsOnly ? ' is-gps-only' : ''}`} onClick={isGpsOnly ? undefined : () => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
<div className="rcard-info">
<div className="rcard-name">{r.riderName}</div>
<div className="rcard-zone">{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
<div className="rcard-zone">
{isGpsOnly
? 'Live GPS · no active delivery'
: `${r.orders[0]?.zone_name || locationName || 'Local'} · ${new Set(r.orders.map(o => o.trip_number || 1)).size} trips`}
</div>
</div>
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
title={`${delivered} delivered of ${total} total`}
>
{delivered}/{total}
</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta">
<span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
{estMeters !== null && (
<span className="rcard-est-meters" title="Estimated distance to next drop location">
<Ico><MdMyLocation /></Ico>{formatMeters(estMeters)} to drop
</span>
{isGpsOnly ? (
<div className="rcard-badge rcard-badge-live" title="Rider is live on GPS with no active delivery">
<span className="rcard-live-dot" /> LIVE
</div>
) : (
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
title={`${delivered} delivered of ${total} total`}
>
{delivered}/{total}
</div>
)}
</div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
{!isGpsOnly && (
<>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta">
<span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
{estMeters !== null && (
<span className="rcard-est-meters" title="Estimated distance to next drop location">
<Ico><MdMyLocation /></Ico>{formatMeters(estMeters)} to drop
</span>
)}
</div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
</>
)}
</div>
);
};
@@ -2298,6 +2539,11 @@ const Dispatch = ({
<div className="pu-rider">
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
</div>
{(o.deliverycustomer || o.customername) && (
<div className="pu-customer" title={o.deliverycustomer || o.customername}>
<MdPerson /> <span>{o.deliverycustomer || o.customername}</span>
</div>
)}
{o.deliveryid != null && (
<div className="pu-delivery-id">Delivery #{o.deliveryid}</div>
)}
@@ -2412,7 +2658,9 @@ const Dispatch = ({
// duplicate, slightly-offset pins that clutter the view.
if (compareOpen && focusedRider && compareViewMode === 'actual') return null;
let ordersToRender = allOrders;
// In "All Active Routes" view the base set is restricted to active riders'
// orders (allViewOrders); a focus selection still overrides as usual.
let ordersToRender = allViewOrders;
if (focusedZone) ordersToRender = focusedZone.orders;
if (focusedKitchen) ordersToRender = focusedKitchen.orders;
if (focusedRider) ordersToRender = focusedRider.orders;
@@ -2531,7 +2779,9 @@ const Dispatch = ({
const routes = [];
const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null;
if (hidePlanned) return routes;
riders.forEach(r => {
// visibleRiders === riders in every view except "All Active Routes", where
// it's pre-filtered to riders whose live GPS is currently active.
visibleRiders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
@@ -3025,7 +3275,7 @@ const Dispatch = ({
onClick={() => { logger.info('View mode changed: By Zone'); setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
><span className="sbt-icon"><MdMap /></span> By Zone</button>
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Rider'); setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span> All Routes</button>
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Active Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span>Active</button>
<button
type="button"
className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`}
@@ -3574,10 +3824,31 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const activeOrderId = activeOrder ? activeOrder.orderid : null;
// Completion timestamp used by the "By time" sort — prefer the
// actual deliverytime, fall back to the expected one, and push
// rows with neither to the very end (MAX_SAFE_INTEGER).
const completionTs = (o) => {
const t = o.deliverytime || o.expecteddeliverytime;
if (!t) return Number.MAX_SAFE_INTEGER;
const d = dayjs(t);
return d.isValid() ? d.valueOf() : Number.MAX_SAFE_INTEGER;
};
const isTimeMode = tripSortMode === 'time';
let prevKitchenKey = null;
return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => (
.map(([tNum, tOrders]) => {
// 'planned' keeps the incoming step order; 'time' re-sorts
// inside the trip by completion time with step as tiebreaker
// so two drops logged the same minute stay in dispatch order.
const displayOrders = isTimeMode
? [...tOrders].sort((a, b) => {
const diff = completionTs(a) - completionTs(b);
if (diff !== 0) return diff;
return (a.step || 0) - (b.step || 0);
})
: tOrders;
return (
<div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
@@ -3585,9 +3856,40 @@ const Dispatch = ({
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span>
{/* iOS-style segmented control: Planned (dispatched step
order) vs By time (completion order). The active item is
a white "thumb"; the rider color stays on the Trip badge
so the pills don't compete with it. */}
<div
className="trip-sort-toggle"
role="group"
aria-label="Sort stops by"
data-mode={isTimeMode ? 'time' : 'planned'}
>
<button
type="button"
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
aria-pressed={!isTimeMode}
onClick={() => setTripSortMode('planned')}
title="Sort stops by planned step (dispatched order)"
>
<MdFormatListBulleted aria-hidden="true" />
<span>Planned</span>
</button>
<button
type="button"
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
aria-pressed={isTimeMode}
onClick={() => setTripSortMode('time')}
title="Sort stops by completion time (which delivery was done first)"
>
<MdAccessTime aria-hidden="true" />
<span>By time</span>
</button>
</div>
</div>
<div className="zone-order-grid">
{tOrders.map((o, idx) => {
{displayOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
@@ -3598,6 +3900,10 @@ const Dispatch = ({
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const statusStyle = getStatusStyle(o.orderstatus);
const estMeters = calculateEstMeters(focusedRider.id, o);
// In "By time" mode, stops with no actual delivery time
// were sunk to the bottom — dim them so it's obvious
// they're not yet delivered without reading the status.
const isUndeliveredInTimeMode = isTimeMode && !o.deliverytime;
return (
<React.Fragment key={o.orderid}>
@@ -3605,7 +3911,7 @@ const Dispatch = ({
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)}
<div
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''}`}
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''} ${isUndeliveredInTimeMode ? 'is-pending-time' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
@@ -3714,7 +4020,8 @@ const Dispatch = ({
})}
</div>
</div>
));
);
});
})()}
</>
) : (
@@ -3996,7 +4303,8 @@ const Dispatch = ({
<div className="ph">{
viewMode === 'zones' ? 'Zone dispatch' :
viewMode === 'kitchens' ? 'Kitchen dispatch' :
'Rider dispatch'
viewMode === 'all' ? 'Active rider dispatch' :
'Rider dispatch'
}</div>
<div id="rider-cards">
{allOrders.length === 0 && !liveIsFetching ? (
@@ -4114,8 +4422,18 @@ const Dispatch = ({
</div>
</div>
))
) : isAllActiveView && visibleRiders.length === 0 ? (
<div className="empty-slot">
<div className="empty-slot-icon">
<MdTwoWheeler />
</div>
<div className="empty-slot-title">No active riders</div>
<div className="empty-slot-sub">
No riders are currently live on the road for this slot
</div>
</div>
) : (
riders.map(renderRiderCard)
visibleRiders.map(renderRiderCard)
)}
</div>
</div>
@@ -4140,7 +4458,7 @@ const Dispatch = ({
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} kitchens={kitchens} locationKey={selectedAppLocationId} />
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
{kitchens
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
@@ -4173,14 +4491,23 @@ const Dispatch = ({
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
Reports → Riders Logs map: green pin when the rider's last log
row is `active`, red otherwise, with the rider's username as a
label. Scoped to riders who actually have orders in the
currently selected slot — `riders` is derived from
filteredLiveRows so it already reflects the slot filter. A
rider with zero orders in the current slot is hidden, even if
getriderlogs still returns their GPS row. When a specific
rider is focused, only that one is shown. */}
label.
"All Active Routes" view: show EVERY active rider's live GPS —
including riders with no orders in the current slot (those get a
bike marker only, no route). Riders with an active delivery also
get their route drawn by renderRoutes(). Other views stay scoped
to riders who actually have orders in the slot (`riders` is
derived from filteredLiveRows so it already reflects the slot
filter); a rider with zero orders is hidden there even if
getriderlogs still returns their GPS row. When a specific rider
is focused, only that one is shown. */}
{liveRiderLocations
.filter((r) => riders.some((rd) => String(rd.id) === String(r.id)))
.filter((r) =>
isAllActiveView
? r.status === 'active'
: riders.some((rd) => String(rd.id) === String(r.id))
)
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
.map((r) => {
const isActive = r.status === 'active';
@@ -4204,39 +4531,82 @@ const Dispatch = ({
const nextDropArea = nextOrder
? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress))
: null;
const liveIcon = L.divIcon({
className: '',
iconSize: [140, 56],
iconAnchor: [12, 41],
popupAnchor: [58, -40],
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div>
<div class="live-rider-pin-label">${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` <span>#${String(r.orderid).replace(/[<>&"']/g, '')}</span>` : ''}</div>
// Marker icon. ONLY the "All Active Routes" view gets the
// Swiggy/Zomato/Rapido-style live bike badge (rounded glyph
// + pulsing ring) that smoothly glides between GPS fixes.
// Every other view keeps the original teardrop pin exactly
// as before. Icons are cached per rider (liveIconCacheRef);
// the sig includes the view so switching modes rebuilds it.
const safeName = (r.username || '').replace(/[<>&"']/g, '');
const safeOrder = r.orderid ? String(r.orderid).replace(/[<>&"']/g, '') : '';
const iconSig = `${isAllActiveView ? 'bike' : 'pin'}|${pinColor}|${safeName}|${safeOrder}|${isActive ? 1 : 0}`;
let iconEntry = liveIconCacheRef.current.get(r.id);
if (!iconEntry || iconEntry.sig !== iconSig) {
const icon = isAllActiveView
? L.divIcon({
className: '',
iconSize: [160, 44],
iconAnchor: [22, 22],
popupAnchor: [0, -22],
html: `<div class="live-rider-bike ${isActive ? 'is-active' : 'is-idle'}" style="--pin-color:${pinColor}">
<span class="live-rider-bike-pulse"></span>
<span class="live-rider-bike-badge">
<svg viewBox="0 0 24 24" width="20" height="20" fill="#fff" aria-hidden="true"><path d="M19.44 9.03 15.41 5H11v2h3.59l2 2H5c-2.8 0-5 2.2-5 5s2.2 5 5 5c2.46 0 4.45-1.69 4.9-4h1.65l2.77-2.77c-.21.54-.32 1.14-.32 1.77 0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.65-2.06-4.77-4.66-4.97ZM7.82 15C7.4 16.15 6.28 17 5 17c-1.63 0-3-1.37-3-3s1.37-3 3-3c1.28 0 2.4.85 2.82 2H5v2h2.82ZM19 17c-1.63 0-3-1.37-3-3s1.37-3 3-3 3 1.37 3 3-1.37 3-3 3Z"/></svg>
</span>
<span class="live-rider-bike-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</span>
</div>`
});
})
: L.divIcon({
className: '',
iconSize: [140, 56],
iconAnchor: [12, 41],
popupAnchor: [58, -40],
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div>
<div class="live-rider-pin-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</div>
</div>`
});
iconEntry = { sig: iconSig, icon };
liveIconCacheRef.current.set(r.id, iconEntry);
}
const liveIcon = iconEntry.icon;
// Shared interaction handlers — identical for both marker types.
const liveEventHandlers = {
click: (e) => {
const idStr = String(r.id);
if (pinnedLivePopupsRef.current.has(idStr)) {
pinnedLivePopupsRef.current.delete(idStr);
e.target.closePopup();
} else {
pinnedLivePopupsRef.current.add(idStr);
e.target.openPopup();
}
// Focus the rider behind this marker — only riders that
// actually have a delivery (i.e. exist in `riders`).
// GPS-only active riders have no route to focus, so their
// marker just shows the live popup and isn't clickable
// for focus.
const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match);
},
popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id));
}
};
// Animated bike (with imperative gliding) only in the active
// view; the plain react-leaflet Marker everywhere else so
// existing views behave exactly as they did before.
const LiveMarker = isAllActiveView ? AnimatedRiderMarker : Marker;
const positionProps = isAllActiveView
? { target: [r.lat, r.lon], duration: 1200 }
: { position: [r.lat, r.lon] };
return (
<Marker
<LiveMarker
key={`live-${r.id}`}
position={[r.lat, r.lon]}
{...positionProps}
icon={liveIcon}
zIndexOffset={2500}
eventHandlers={{
click: (e) => {
const idStr = String(r.id);
if (pinnedLivePopupsRef.current.has(idStr)) {
pinnedLivePopupsRef.current.delete(idStr);
e.target.closePopup();
} else {
pinnedLivePopupsRef.current.add(idStr);
e.target.openPopup();
}
const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match);
},
popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id));
}
}}
eventHandlers={liveEventHandlers}
>
<Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup">
<div className="pu-hdr-live">
@@ -4322,7 +4692,7 @@ const Dispatch = ({
</div>
</div>
</Popup>
</Marker>
</LiveMarker>
);
})}