updates on the dispatch active page and the navbar design
This commit is contained in:
@@ -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='© 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>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user