updates on the active page and the profitability section
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!riderMap[key].orders.some(existing => existing.orderid === o.orderid)) {
|
||||||
riderMap[key].orders.push(o);
|
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,23 +4952,47 @@ 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
|
||||||
|
// 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: '',
|
className: '',
|
||||||
iconSize: [140, 56],
|
iconSize: [140, 56],
|
||||||
iconAnchor: [12, 41],
|
iconAnchor: [12, 41],
|
||||||
popupAnchor: [58, -40],
|
popupAnchor: [58, -40],
|
||||||
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
|
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
|
||||||
<div class="live-rider-pin-marker"></div>
|
<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>
|
<div class="live-rider-pin-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</div>
|
||||||
</div>`
|
</div>`
|
||||||
});
|
});
|
||||||
return (
|
iconEntry = { sig: iconSig, icon };
|
||||||
<Marker
|
liveIconCacheRef.current.set(r.id, iconEntry);
|
||||||
key={`live-${r.id}`}
|
}
|
||||||
position={[r.lat, r.lon]}
|
const liveIcon = iconEntry.icon;
|
||||||
icon={liveIcon}
|
// Shared interaction handlers — identical for both marker types.
|
||||||
zIndexOffset={2500}
|
const liveEventHandlers = {
|
||||||
eventHandlers={{
|
|
||||||
click: (e) => {
|
click: (e) => {
|
||||||
const idStr = String(r.id);
|
const idStr = String(r.id);
|
||||||
if (pinnedLivePopupsRef.current.has(idStr)) {
|
if (pinnedLivePopupsRef.current.has(idStr)) {
|
||||||
@@ -4776,7 +5008,21 @@ const Dispatch = ({
|
|||||||
popupclose: () => {
|
popupclose: () => {
|
||||||
pinnedLivePopupsRef.current.delete(String(r.id));
|
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 (
|
||||||
|
<LiveMarker
|
||||||
|
key={`live-${r.id}`}
|
||||||
|
{...positionProps}
|
||||||
|
icon={liveIcon}
|
||||||
|
zIndexOffset={2500}
|
||||||
|
eventHandlers={liveEventHandlers}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user