updates on the active page and the profitability section
This commit is contained in:
@@ -10986,4 +10986,87 @@
|
||||
.dispatch-container #dispatch-analysis {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -779,6 +779,125 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
|
||||
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 }) => (
|
||||
@@ -972,6 +1091,8 @@ 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());
|
||||
// Cache for live GPS rider Leaflet divIcon instances.
|
||||
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.
|
||||
@@ -1334,7 +1455,7 @@ 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.
|
||||
const RIDER_LOG_POLL_MS = viewMode === 'all' ? 1_000 : 15_000;
|
||||
const RIDER_LOG_POLL_MS = 15000;
|
||||
const { data: ridersLocationLogs } = useQuery({
|
||||
queryKey: [selectedAppLocationId, selectedDate, ''],
|
||||
queryFn: fetchRidersLogs,
|
||||
@@ -1484,24 +1605,24 @@ const Dispatch = ({
|
||||
const totalDailyProfit = useMemo(() => {
|
||||
let profit = 0;
|
||||
const slotRiders = {};
|
||||
|
||||
|
||||
liveRows.forEach(r => {
|
||||
const batch = getRowBatch(r, selectedTimeField, BATCHES);
|
||||
if (!batch || batch === 'all') return;
|
||||
|
||||
|
||||
const riderKey = String(r.userid || r.rider_id || 'unassigned');
|
||||
if (riderKey === 'unassigned' || riderKey === '0') return;
|
||||
|
||||
|
||||
if (!slotRiders[batch]) slotRiders[batch] = {};
|
||||
if (!slotRiders[batch][riderKey]) {
|
||||
slotRiders[batch][riderKey] = { revenue: 0, kms: 0 };
|
||||
}
|
||||
|
||||
|
||||
const kms = parseFloat(r.kms || r.actualkms || 0);
|
||||
slotRiders[batch][riderKey].kms += kms;
|
||||
slotRiders[batch][riderKey].revenue += (kms <= 8 ? 30 : 30 + (kms - 8) * 6);
|
||||
});
|
||||
|
||||
|
||||
Object.values(slotRiders).forEach(riderMap => {
|
||||
Object.values(riderMap).forEach(stats => {
|
||||
const variableCost = stats.kms * 2.5;
|
||||
@@ -1510,7 +1631,7 @@ const Dispatch = ({
|
||||
profit += (stats.revenue - totalCost);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return profit;
|
||||
}, [liveRows, selectedTimeField, BATCHES]);
|
||||
|
||||
@@ -1636,8 +1757,27 @@ const Dispatch = ({
|
||||
});
|
||||
|
||||
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 => {
|
||||
const key = o.rider_id || o.userid || 'unknown';
|
||||
if (key === 'unassigned') return;
|
||||
if (!riderMap[key]) {
|
||||
riderMap[key] = {
|
||||
id: key,
|
||||
@@ -1646,7 +1786,9 @@ const Dispatch = ({
|
||||
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 = {};
|
||||
@@ -1846,7 +1988,7 @@ const Dispatch = ({
|
||||
// is already gated to active-order riders) so the tiles can't disagree with
|
||||
// the list/map below.
|
||||
if (isAllActiveView) {
|
||||
const activeOrders = allViewOrders.filter(isActiveDelivery);
|
||||
const activeOrders = allOrders.filter(isActiveDelivery);
|
||||
return {
|
||||
orders: activeOrders.length,
|
||||
riders: visibleRiders.length,
|
||||
@@ -2299,8 +2441,30 @@ const Dispatch = ({
|
||||
const pts = buildTripPoints(sorted);
|
||||
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
|
||||
// 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).
|
||||
// Wait ~350ms so MapController has a chance to recenter first (matches
|
||||
// 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(() => {
|
||||
if (!focusedStop) return;
|
||||
const t = setTimeout(() => {
|
||||
@@ -2361,6 +2527,7 @@ const Dispatch = ({
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [focusedStop, allOrders]);
|
||||
*/
|
||||
|
||||
const startAnimation = () => {
|
||||
if (isAnimating) {
|
||||
@@ -2902,13 +3069,20 @@ 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;
|
||||
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 = {};
|
||||
rOrders.forEach(o => {
|
||||
const t = o.trip_number || 1;
|
||||
@@ -2933,16 +3107,46 @@ const Dispatch = ({
|
||||
const roadPoints = osrmRoutes[cacheKey];
|
||||
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||||
|
||||
// Cache values:
|
||||
// Array → OSRM road polyline (use it)
|
||||
// false → OSRM permanently failed (draw aerial fallback so user sees something)
|
||||
// null → request in-flight (DON'T draw anything yet — avoids the aerial flash)
|
||||
// undefined → not yet requested (same as in-flight, wait)
|
||||
const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2;
|
||||
const failed = roadPoints === false;
|
||||
if (!hasRoad && !failed) return; // still loading — don't show aerial flash
|
||||
// Active view: build a route scoped to just the in-progress leg and FOLLOW THE ROADS.
|
||||
//
|
||||
// Leg endpoints: the rider's CURRENT live GPS position → the drop. The
|
||||
// live position is preferred because (a) active riders always have a
|
||||
// live GPS fix in this view, so the leg always has 2 valid points even
|
||||
// when the order carries no pickup coordinates — the previous cause of a
|
||||
// completely missing line — and (b) it shows the rider's REMAINING route
|
||||
// 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;
|
||||
|
||||
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
|
||||
@@ -4720,7 +4924,11 @@ const Dispatch = ({
|
||||
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' && activeOrderRiderIdSet.has(String(r.id)))
|
||||
: 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';
|
||||
@@ -4744,39 +4952,77 @@ 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();
|
||||
}
|
||||
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 (
|
||||
<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">
|
||||
@@ -4862,7 +5108,7 @@ const Dispatch = ({
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</LiveMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ body {
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
|
||||
min-height: 100vh;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -134,9 +135,9 @@ body {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
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%);
|
||||
border: 1px solid var(--border-light);
|
||||
@@ -149,17 +150,17 @@ body {
|
||||
.profitability-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xl);
|
||||
gap: var(--space-md);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.profitability-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--primary), #7C58E8);
|
||||
|
||||
display: flex;
|
||||
@@ -167,9 +168,9 @@ body {
|
||||
justify-content: center;
|
||||
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-size: 18px;
|
||||
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -177,35 +178,35 @@ body {
|
||||
.profitability-header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profitability-header-content h2 {
|
||||
font-size: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.profitability-header-title {
|
||||
font-size: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.profitability-header-subtitle {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -232,7 +233,7 @@ body {
|
||||
|
||||
.header-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
@@ -242,12 +243,12 @@ body {
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
display: inline-block;
|
||||
margin: 0 6px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.profitability-kpi-group {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -255,11 +256,11 @@ body {
|
||||
.profitability-dashboard .profitability-kpi-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
gap: var(--space-sm);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: all var(--duration-base) var(--easing);
|
||||
@@ -278,12 +279,12 @@ body {
|
||||
}
|
||||
|
||||
.profitability-kpi-chip:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.profitability-kpi-chip-icon {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
@@ -292,20 +293,20 @@ body {
|
||||
.profitability-kpi-chip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.profitability-kpi-chip-label {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profitability-kpi-chip-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
MdRoute,
|
||||
MdLocationOn,
|
||||
MdBarChart,
|
||||
MdPeopleAlt,
|
||||
MdSearch
|
||||
MdPeopleAlt
|
||||
} from 'react-icons/md';
|
||||
import './ProfitabilitySection.css';
|
||||
|
||||
@@ -161,12 +160,12 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
|
||||
const km = parseFloat(order.kms ?? order.actualkms ?? 0);
|
||||
const rev = getRevenue(order);
|
||||
return (
|
||||
<tr key={order.orderid ?? idx}>
|
||||
<tr key={`${order.orderid ?? 'order'}-${idx}`}>
|
||||
<td>
|
||||
<CustomerCell order={order} />
|
||||
</td>
|
||||
<td>
|
||||
<OrderStatusPill status={order.status} />
|
||||
<OrderStatusPill status={order.orderstatus ?? order.status} />
|
||||
</td>
|
||||
<td>
|
||||
<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 }) {
|
||||
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 toggleRider = useCallback((id) => {
|
||||
@@ -381,23 +378,8 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
|
||||
const dailyIsProfit = totalDailyProfit >= 0;
|
||||
const slotIsProfit = slotNet >= 0;
|
||||
|
||||
// Filter riders based on search query and filter tabs
|
||||
const filtered = useMemo(() => {
|
||||
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]);
|
||||
// Filter riders (filtering by search and status tabs removed)
|
||||
const filtered = enriched;
|
||||
|
||||
// Sort riders based on selected sortMode
|
||||
const sortedAndFiltered = useMemo(() => {
|
||||
@@ -424,7 +406,7 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
|
||||
<header className="profitability-header">
|
||||
<div className="profitability-header-left">
|
||||
<div className="profitability-header-icon" aria-hidden="true">
|
||||
<MdBarChart size={24} />
|
||||
<MdBarChart size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="profitability-header-title">Profitability Overview</h2>
|
||||
@@ -496,58 +478,7 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls (Search, Filter, Sort) ── */}
|
||||
<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>
|
||||
{/* ── Controls (Search) (removed) ── */}
|
||||
|
||||
{/* ── Rider feed ── */}
|
||||
<div className="rider-profitability-feed" role="list" aria-label="Rider cards">
|
||||
|
||||
Reference in New Issue
Block a user