updates on the active page and the profitability section

This commit is contained in:
2026-06-16 15:33:06 +05:30
parent 7858d42f86
commit ffcd9440e6
4 changed files with 419 additions and 158 deletions

View File

@@ -10987,3 +10987,86 @@
overflow-y: visible; overflow-y: visible;
} }
} }
/* ── Live Rider Bike Marker (Swiggy/Zomato style) ── */
.dispatch-container .live-rider-bike {
--pin-color: #16a34a;
position: relative;
width: 160px;
height: 44px;
pointer-events: none;
}
.dispatch-container .live-rider-bike-badge {
position: absolute;
left: 4px;
top: 4px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--pin-color);
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
pointer-events: auto;
z-index: 2;
}
.dispatch-container .live-rider-bike-pulse {
position: absolute;
left: 4px;
top: 4px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--pin-color);
opacity: 0.45;
z-index: 1;
}
.dispatch-container .live-rider-bike.is-active .live-rider-bike-pulse {
animation: live-rider-bike-pulse 1.6s ease-out infinite;
}
.dispatch-container .live-rider-bike.is-idle .live-rider-bike-badge {
opacity: 0.85;
}
.dispatch-container .live-rider-bike-label {
position: absolute;
left: 46px;
top: 9px;
background: var(--pin-color);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
line-height: 1.2;
pointer-events: auto;
}
.dispatch-container .live-rider-bike-label span {
font-weight: 500;
opacity: 0.85;
margin-left: 4px;
}
@keyframes live-rider-bike-pulse {
0% {
transform: scale(1);
opacity: 0.5;
}
70% {
transform: scale(2.2);
opacity: 0;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}

View File

@@ -779,6 +779,125 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
return null; 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 // 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. // SVG vertically centered with the adjacent text and inherits the parent color.
const Ico = ({ children }) => ( const Ico = ({ children }) => (
@@ -972,6 +1091,8 @@ const Dispatch = ({
// popups use leaflet's marker-attached <Popup> (openPopup/closePopup) rather // popups use leaflet's marker-attached <Popup> (openPopup/closePopup) rather
// than the centered overlay used for order popups. // than the centered overlay used for order popups.
const pinnedLivePopupsRef = useRef(new Set()); const pinnedLivePopupsRef = useRef(new Set());
// Cache for live GPS rider Leaflet divIcon instances.
const liveIconCacheRef = useRef(new Map());
// Short-lived close timer for the general map order/marker popups. // 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 // Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close. // or vice versa without immediately triggering a close.
@@ -1334,7 +1455,7 @@ const Dispatch = ({
// hub (latitude/longitude/logdate/status). We render those positions as // hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider // markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page. // actually is — matching the Reports → Riders Logs page.
const RIDER_LOG_POLL_MS = viewMode === 'all' ? 1_000 : 15_000; const RIDER_LOG_POLL_MS = 15000;
const { data: ridersLocationLogs } = useQuery({ const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''], queryKey: [selectedAppLocationId, selectedDate, ''],
queryFn: fetchRidersLogs, queryFn: fetchRidersLogs,
@@ -1636,8 +1757,27 @@ const Dispatch = ({
}); });
const riderMap = {}; const riderMap = {};
// First, populate riderMap with all riders present in the zones to capture those with 0 orders
(source.zones || []).forEach(z => {
(z.riders || []).forEach(r => {
const key = r.rider_id;
if (!key || key === 'unassigned') return;
if (!riderMap[key]) {
riderMap[key] = {
id: key,
riderName: r.rider_name || r.username || key,
orders: [],
color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length]
};
}
});
});
// Next, map the flat orders list to the riders
orders.forEach(o => { orders.forEach(o => {
const key = o.rider_id || o.userid || 'unknown'; const key = o.rider_id || o.userid || 'unknown';
if (key === 'unassigned') return;
if (!riderMap[key]) { if (!riderMap[key]) {
riderMap[key] = { riderMap[key] = {
id: key, id: key,
@@ -1646,7 +1786,9 @@ const Dispatch = ({
color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length] color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length]
}; };
} }
riderMap[key].orders.push(o); if (!riderMap[key].orders.some(existing => existing.orderid === o.orderid)) {
riderMap[key].orders.push(o);
}
}); });
const kitchenMap = {}; const kitchenMap = {};
@@ -1846,7 +1988,7 @@ const Dispatch = ({
// is already gated to active-order riders) so the tiles can't disagree with // is already gated to active-order riders) so the tiles can't disagree with
// the list/map below. // the list/map below.
if (isAllActiveView) { if (isAllActiveView) {
const activeOrders = allViewOrders.filter(isActiveDelivery); const activeOrders = allOrders.filter(isActiveDelivery);
return { return {
orders: activeOrders.length, orders: activeOrders.length,
riders: visibleRiders.length, riders: visibleRiders.length,
@@ -2299,8 +2441,30 @@ const Dispatch = ({
const pts = buildTripPoints(sorted); const pts = buildTripPoints(sorted);
if (pts.length >= 2) fetchRoute(r.id, tNum, pts); if (pts.length >= 2) fetchRoute(r.id, tNum, pts);
}); });
// Active view: fetch a road-following route scoped to the rider's single
// in-progress leg — current live GPS position → drop (pickup as fallback
// when no live fix). Cached under a per-order key so renderRoutes() can
// draw the road polyline for that leg instead of a straight line, and so
// it never collides with the per-trip route fetched above.
if (isAllActiveView) {
const activeOrder = getActiveOrder(r.orders);
if (activeOrder) {
const lp = liveRiderLocations.find((l) => String(l.id) === String(r.id));
const start = (lp && Number.isFinite(lp.lat) && Number.isFinite(lp.lon))
? [lp.lat, lp.lon]
: (hasValidPickup(activeOrder)
? [toNum(pickupLat(activeOrder)), toNum(pickupLon(activeOrder))]
: null);
const dLat = toNum(activeOrder.droplat || activeOrder.deliverylat);
const dLon = toNum(activeOrder.droplon || activeOrder.deliverylong);
if (start && Number.isFinite(dLat) && Number.isFinite(dLon)) {
fetchRoute(r.id, `active-${activeOrder.orderid}`, [start, [dLat, dLon]]);
}
}
}
}); });
}, [riders, activeRiders, focusedRider, fetchRoute]); }, [riders, activeRiders, focusedRider, isAllActiveView, liveRiderLocations, fetchRoute]);
// Auto-advance the selected slot when the wall-clock moves into a new slot's // Auto-advance the selected slot when the wall-clock moves into a new slot's
// window — BUT only if the user is still sitting on the slot that's just been // window — BUT only if the user is still sitting on the slot that's just been
@@ -2353,6 +2517,8 @@ const Dispatch = ({
// (renderOrderPopupContent needs the rich timeline + status fields). // (renderOrderPopupContent needs the rich timeline + status fields).
// Wait ~350ms so MapController has a chance to recenter first (matches // Wait ~350ms so MapController has a chance to recenter first (matches
// the prior delay before openPopup was called). // the prior delay before openPopup was called).
// COMMENTED OUT: as per user request to only show the popup when clicking the map number plots/markers.
/*
useEffect(() => { useEffect(() => {
if (!focusedStop) return; if (!focusedStop) return;
const t = setTimeout(() => { const t = setTimeout(() => {
@@ -2361,6 +2527,7 @@ const Dispatch = ({
}, 350); }, 350);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [focusedStop, allOrders]); }, [focusedStop, allOrders]);
*/
const startAnimation = () => { const startAnimation = () => {
if (isAnimating) { if (isAnimating) {
@@ -2902,13 +3069,20 @@ const Dispatch = ({
const routes = []; const routes = [];
const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null; const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null;
if (hidePlanned) return routes; 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); const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return; if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return; if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
if (zoneRiderIds && !zoneRiderIds.has(String(r.id))) return; if (zoneRiderIds && !zoneRiderIds.has(String(r.id))) return;
const rOrders = r.orders; // In the Active view, collapse the rider down to just their in-progress
// delivery so the map draws only that one leg's polyline instead of the
// whole day's route. Every other view keeps the full order list.
const activeOrder = isAllActiveView ? getActiveOrder(r.orders) : null;
const rOrders = isAllActiveView ? (activeOrder ? [activeOrder] : []) : r.orders;
if (isAllActiveView && rOrders.length === 0) return;
const trips = {}; const trips = {};
rOrders.forEach(o => { rOrders.forEach(o => {
const t = o.trip_number || 1; const t = o.trip_number || 1;
@@ -2933,16 +3107,46 @@ const Dispatch = ({
const roadPoints = osrmRoutes[cacheKey]; const roadPoints = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Cache values: // Active view: build a route scoped to just the in-progress leg and FOLLOW THE ROADS.
// Array → OSRM road polyline (use it) //
// false → OSRM permanently failed (draw aerial fallback so user sees something) // Leg endpoints: the rider's CURRENT live GPS position → the drop. The
// null → request in-flight (DON'T draw anything yet — avoids the aerial flash) // live position is preferred because (a) active riders always have a
// undefined → not yet requested (same as in-flight, wait) // live GPS fix in this view, so the leg always has 2 valid points even
const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2; // when the order carries no pickup coordinates — the previous cause of a
const failed = roadPoints === false; // completely missing line — and (b) it shows the rider's REMAINING route
if (!hasRoad && !failed) return; // still loading — don't show aerial flash // to the customer. Falls back to the pickup when there's no live fix.
const activeLegOrder = isAllActiveView ? sorted[0] : null;
let activeStraightLeg = null;
if (isAllActiveView && activeLegOrder) {
const lp = liveRiderLocations.find((l) => String(l.id) === String(r.id));
const start = (lp && Number.isFinite(lp.lat) && Number.isFinite(lp.lon))
? [lp.lat, lp.lon]
: (hasValidPickup(activeLegOrder)
? [toNum(pickupLat(activeLegOrder)), toNum(pickupLon(activeLegOrder))]
: null);
const dLat = toNum(activeLegOrder.droplat || activeLegOrder.deliverylat);
const dLon = toNum(activeLegOrder.droplon || activeLegOrder.deliverylong);
if (start && Number.isFinite(dLat) && Number.isFinite(dLon)) {
activeStraightLeg = [start, [dLat, dLon]];
}
}
// Prefer the OSRM road-following route for the active leg (fetched as
// `${r.id}-active-${orderid}` in the route-fetch effect); other views
// keep the per-trip route.
const roadToUse = isAllActiveView
? (activeLegOrder && activeStraightLeg ? osrmRoutes[getTripCacheKey(r.id, `active-${activeLegOrder.orderid}`, activeStraightLeg)] : undefined)
: roadPoints;
const hasRoad = Array.isArray(roadToUse) && roadToUse.length >= 2;
const failed = roadToUse === false;
// Other views: wait for OSRM so we don't flash an aerial line before the
// road polyline lands. Active view: NEVER wait — draw the straight leg
// immediately and let it upgrade to the road-following route, so the
// line is never invisible while OSRM is in flight.
if (!isAllActiveView && !hasRoad && !failed) return;
const finalPoints = hasRoad ? roadPoints : buildTripPoints(sorted); const finalPoints = hasRoad
? roadToUse
: (isAllActiveView ? activeStraightLeg : buildTripPoints(sorted));
if (!finalPoints || finalPoints.length < 2) return; if (!finalPoints || finalPoints.length < 2) return;
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen); const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
@@ -4720,7 +4924,11 @@ const Dispatch = ({
getriderlogs still returns their GPS row. When a specific getriderlogs still returns their GPS row. When a specific
rider is focused, only that one is shown. */} rider is focused, only that one is shown. */}
{liveRiderLocations {liveRiderLocations
.filter((r) => riders.some((rd) => String(rd.id) === String(r.id))) .filter((r) =>
isAllActiveView
? (r.status === 'active' && activeOrderRiderIdSet.has(String(r.id)))
: riders.some((rd) => String(rd.id) === String(r.id))
)
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id)) .filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
.map((r) => { .map((r) => {
const isActive = r.status === 'active'; const isActive = r.status === 'active';
@@ -4744,39 +4952,77 @@ const Dispatch = ({
const nextDropArea = nextOrder const nextDropArea = nextOrder
? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress)) ? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress))
: null; : null;
const liveIcon = L.divIcon({ // Marker icon. ONLY the "All Active Routes" view gets the
className: '', // Swiggy/Zomato/Rapido-style live bike badge (rounded glyph
iconSize: [140, 56], // + pulsing ring) that smoothly glides between GPS fixes.
iconAnchor: [12, 41], // Every other view keeps the original teardrop pin exactly
popupAnchor: [58, -40], // as before. Icons are cached per rider (liveIconCacheRef);
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}"> // the sig includes the view so switching modes rebuilds it.
<div class="live-rider-pin-marker"></div> const safeName = (r.username || '').replace(/[<>&"']/g, '');
<div class="live-rider-pin-label">${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` <span>#${String(r.orderid).replace(/[<>&"']/g, '')}</span>` : ''}</div> 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>` </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();
}
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: 15000 }
: { position: [r.lat, r.lon] };
return ( return (
<Marker <LiveMarker
key={`live-${r.id}`} key={`live-${r.id}`}
position={[r.lat, r.lon]} {...positionProps}
icon={liveIcon} icon={liveIcon}
zIndexOffset={2500} zIndexOffset={2500}
eventHandlers={{ eventHandlers={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();
}
const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match);
},
popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id));
}
}}
> >
<Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup"> <Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup">
<div className="pu-hdr-live"> <div className="pu-hdr-live">
@@ -4862,7 +5108,7 @@ const Dispatch = ({
</div> </div>
</div> </div>
</Popup> </Popup>
</Marker> </LiveMarker>
); );
})} })}

View File

@@ -100,7 +100,8 @@ body {
line-height: 1.5; line-height: 1.5;
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 0;
flex: 1;
} }
/* Scrollbar styling */ /* Scrollbar styling */
@@ -134,9 +135,9 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-2xl); gap: var(--space-md);
padding: var(--space-2xl); padding: 10px 16px;
background: linear-gradient(135deg, rgba(108, 76, 241, 0.04) 0%, rgba(108, 76, 241, 0.01) 100%); background: linear-gradient(135deg, rgba(108, 76, 241, 0.04) 0%, rgba(108, 76, 241, 0.01) 100%);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
@@ -149,17 +150,17 @@ body {
.profitability-header-left { .profitability-header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2xl); gap: var(--space-md);
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
} }
.profitability-header-icon { .profitability-header-icon {
width: 48px; width: 36px;
height: 48px; height: 36px;
min-width: 48px; min-width: 36px;
border-radius: var(--radius-md); border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--primary), #7C58E8); background: linear-gradient(135deg, var(--primary), #7C58E8);
display: flex; display: flex;
@@ -167,9 +168,9 @@ body {
justify-content: center; justify-content: center;
color: white; color: white;
font-size: 24px; font-size: 18px;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-sm);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -177,35 +178,35 @@ body {
.profitability-header-content { .profitability-header-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-xs);
min-width: 0; min-width: 0;
} }
.profitability-header-content h2 { .profitability-header-content h2 {
font-size: 24px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
letter-spacing: -0.02em; letter-spacing: -0.01em;
line-height: 1.2; line-height: 1.2;
} }
.profitability-header-title { .profitability-header-title {
font-size: 24px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
letter-spacing: -0.02em; letter-spacing: -0.01em;
line-height: 1.2; line-height: 1.2;
} }
.profitability-header-subtitle { .profitability-header-subtitle {
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-lg); gap: var(--space-sm);
flex-wrap: wrap; flex-wrap: wrap;
margin: 0; margin: 0;
} }
@@ -232,7 +233,7 @@ body {
.header-divider { .header-divider {
width: 1px; width: 1px;
height: 20px; height: 16px;
background: var(--border); background: var(--border);
} }
@@ -242,12 +243,12 @@ body {
border-radius: 50%; border-radius: 50%;
background: var(--text-tertiary); background: var(--text-tertiary);
display: inline-block; display: inline-block;
margin: 0 6px; margin: 0 4px;
} }
.profitability-kpi-group { .profitability-kpi-group {
display: flex; display: flex;
gap: var(--space-md); gap: var(--space-sm);
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -255,11 +256,11 @@ body {
.profitability-dashboard .profitability-kpi-chip { .profitability-dashboard .profitability-kpi-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-md); gap: var(--space-sm);
padding: var(--space-md) var(--space-lg); padding: 6px 12px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid; border: 1px solid;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
transition: all var(--duration-base) var(--easing); transition: all var(--duration-base) var(--easing);
@@ -278,12 +279,12 @@ body {
} }
.profitability-kpi-chip:hover { .profitability-kpi-chip:hover {
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-sm);
} }
.profitability-kpi-chip-icon { .profitability-kpi-chip-icon {
font-size: 16px; font-size: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
@@ -292,20 +293,20 @@ body {
.profitability-kpi-chip-content { .profitability-kpi-chip-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
} }
.profitability-kpi-chip-label { .profitability-kpi-chip-label {
font-size: 10px; font-size: 9px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.05em;
opacity: 0.8; opacity: 0.8;
} }
.profitability-kpi-chip-amount { .profitability-kpi-chip-amount {
font-size: 16px; font-size: 13px;
font-weight: 800; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }

View File

@@ -9,8 +9,7 @@ import {
MdRoute, MdRoute,
MdLocationOn, MdLocationOn,
MdBarChart, MdBarChart,
MdPeopleAlt, MdPeopleAlt
MdSearch
} from 'react-icons/md'; } from 'react-icons/md';
import './ProfitabilitySection.css'; import './ProfitabilitySection.css';
@@ -161,12 +160,12 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
const km = parseFloat(order.kms ?? order.actualkms ?? 0); const km = parseFloat(order.kms ?? order.actualkms ?? 0);
const rev = getRevenue(order); const rev = getRevenue(order);
return ( return (
<tr key={order.orderid ?? idx}> <tr key={`${order.orderid ?? 'order'}-${idx}`}>
<td> <td>
<CustomerCell order={order} /> <CustomerCell order={order} />
</td> </td>
<td> <td>
<OrderStatusPill status={order.status} /> <OrderStatusPill status={order.orderstatus ?? order.status} />
</td> </td>
<td> <td>
<span className="distance-value">{km.toFixed(1)}</span> <span className="distance-value">{km.toFixed(1)}</span>
@@ -360,8 +359,6 @@ function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggl
*/ */
export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0, focusedRider = null, handleRiderFocus }) { export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0, focusedRider = null, handleRiderFocus }) {
const [expanded, setExpanded] = useState({}); const [expanded, setExpanded] = useState({});
const [searchQuery, setSearchQuery] = useState('');
const [filterTab, setFilterTab] = useState('all'); // 'all', 'profitable', 'loss'
const [sortMode, setSortMode] = useState('profit-asc'); // 'profit-asc', 'profit-desc', 'name-asc', 'orders-desc' const [sortMode, setSortMode] = useState('profit-asc'); // 'profit-asc', 'profit-desc', 'name-asc', 'orders-desc'
const toggleRider = useCallback((id) => { const toggleRider = useCallback((id) => {
@@ -381,23 +378,8 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
const dailyIsProfit = totalDailyProfit >= 0; const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0; const slotIsProfit = slotNet >= 0;
// Filter riders based on search query and filter tabs // Filter riders (filtering by search and status tabs removed)
const filtered = useMemo(() => { const filtered = enriched;
return enriched.filter((r) => {
const name = r.riderName ?? r.username ?? `Rider #${r.id}`;
const matchesSearch = name.toLowerCase().includes(searchQuery.toLowerCase()) || String(r.id).includes(searchQuery);
const isProfitable = r._m.net >= 0;
let matchesTab = true;
if (filterTab === 'profitable') {
matchesTab = isProfitable;
} else if (filterTab === 'loss') {
matchesTab = !isProfitable;
}
return matchesSearch && matchesTab;
});
}, [enriched, searchQuery, filterTab]);
// Sort riders based on selected sortMode // Sort riders based on selected sortMode
const sortedAndFiltered = useMemo(() => { const sortedAndFiltered = useMemo(() => {
@@ -424,7 +406,7 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
<header className="profitability-header"> <header className="profitability-header">
<div className="profitability-header-left"> <div className="profitability-header-left">
<div className="profitability-header-icon" aria-hidden="true"> <div className="profitability-header-icon" aria-hidden="true">
<MdBarChart size={24} /> <MdBarChart size={18} />
</div> </div>
<div> <div>
<h2 className="profitability-header-title">Profitability Overview</h2> <h2 className="profitability-header-title">Profitability Overview</h2>
@@ -496,58 +478,7 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
</div> </div>
</div> </div>
{/* ── Controls (Search, Filter, Sort) ── */} {/* ── Controls (Search) (removed) ── */}
<div className="profitability-controls-bar">
<div className="profitability-search-wrapper">
<MdSearch className="profitability-search-icon" />
<input
type="text"
className="profitability-search-input"
placeholder="Search rider by name or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="profitability-filter-tabs">
<button
type="button"
className={`profitability-tab-btn ${filterTab === 'all' ? 'tab-active' : ''}`}
onClick={() => setFilterTab('all')}
>
All <span className="profitability-tab-count">{enriched.length}</span>
</button>
<button
type="button"
className={`profitability-tab-btn ${filterTab === 'profitable' ? 'tab-active' : ''}`}
onClick={() => setFilterTab('profitable')}
>
Profitable <span className="profitability-tab-count">{profitCount}</span>
</button>
<button
type="button"
className={`profitability-tab-btn ${filterTab === 'loss' ? 'tab-active' : ''}`}
onClick={() => setFilterTab('loss')}
>
At Loss <span className="profitability-tab-count">{lossCount}</span>
</button>
</div>
<div className="profitability-sort-wrapper">
<span className="profitability-sort-label">Sort:</span>
<select
className="profitability-sort-select"
value={sortMode}
onChange={(e) => setSortMode(e.target.value)}
aria-label="Sort riders"
>
<option value="profit-asc">Lowest Profit First</option>
<option value="profit-desc">Highest Profit First</option>
<option value="name-asc">Rider Name (A-Z)</option>
<option value="orders-desc">Orders Count (High-Low)</option>
</select>
</div>
</div>
{/* ── Rider feed ── */} {/* ── Rider feed ── */}
<div className="rider-profitability-feed" role="list" aria-label="Rider cards"> <div className="rider-profitability-feed" role="list" aria-label="Rider cards">