update for the the active section page and the profitability page
This commit is contained in:
131
src/pages/nearle/dispatch/ActiveSection.js
Normal file
131
src/pages/nearle/dispatch/ActiveSection.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
MdTwoWheeler,
|
||||||
|
MdLocationOn,
|
||||||
|
MdRestaurant,
|
||||||
|
MdStraighten,
|
||||||
|
MdMyLocation,
|
||||||
|
MdInventory2
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import { getStatusStyle, getActiveOrder } from './dispatchShared';
|
||||||
|
|
||||||
|
const ActiveSection = ({
|
||||||
|
visibleRiders,
|
||||||
|
riders,
|
||||||
|
focusedStop,
|
||||||
|
handleRiderFocus,
|
||||||
|
setFocusedStop,
|
||||||
|
calculateEstMeters,
|
||||||
|
getRiderColor,
|
||||||
|
formatMeters
|
||||||
|
}) => {
|
||||||
|
const activeDeliveries = visibleRiders
|
||||||
|
.map((r) => getActiveOrder(r.orders))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) =>
|
||||||
|
String(a.rider_name || a.ridername || '').localeCompare(
|
||||||
|
String(b.rider_name || b.ridername || '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeDeliveries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-slot">
|
||||||
|
<div className="empty-slot-icon">
|
||||||
|
<MdInventory2 />
|
||||||
|
</div>
|
||||||
|
<div className="empty-slot-title">No active deliveries</div>
|
||||||
|
<div className="empty-slot-sub">
|
||||||
|
No deliveries are currently in progress for this slot
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderActiveDeliveryCard = (o, i) => {
|
||||||
|
const rid = o.rider_id;
|
||||||
|
const rider = riders.find((r) => String(r.id) === String(rid));
|
||||||
|
const color = getRiderColor(rid);
|
||||||
|
const statusStyle = getStatusStyle(o.orderstatus);
|
||||||
|
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||||
|
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||||
|
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||||
|
const estMeters = calculateEstMeters(rid, o);
|
||||||
|
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
|
||||||
|
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
|
||||||
|
const riderName = o.rider_name || o.ridername || 'Unassigned';
|
||||||
|
const isActive = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
|
||||||
|
const initials =
|
||||||
|
riderName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || '•';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={o.orderid}
|
||||||
|
className={`adcard${isActive ? ' is-active' : ''}`}
|
||||||
|
style={{ '--ad-accent': color, animationDelay: `${i * 0.05}s` }}
|
||||||
|
onClick={() => {
|
||||||
|
if (rider) handleRiderFocus(rider);
|
||||||
|
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="adcard-top">
|
||||||
|
<div className="adcard-avatar" style={{ background: color }}>{initials}</div>
|
||||||
|
<div className="adcard-titles">
|
||||||
|
<div className="adcard-customer" title={customer}>{customer}</div>
|
||||||
|
<div className="adcard-rider" title={riderName}>
|
||||||
|
<MdTwoWheeler style={{ fontSize: 13, flexShrink: 0 }} />
|
||||||
|
<span className="adcard-tx">{riderName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="adcard-status"
|
||||||
|
style={{ background: `${statusStyle.bg}1a`, color: statusStyle.bg }}
|
||||||
|
title={statusStyle.label}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dropArea && (
|
||||||
|
<div className="adcard-addr">
|
||||||
|
<span className="adcard-ic"><MdLocationOn /></span>
|
||||||
|
<span className="adcard-tx adcard-addr-tx" title={dropArea}>{dropArea}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="adcard-foot">
|
||||||
|
{o.pickupcustomer ? (
|
||||||
|
<span className="adcard-pickup" title={o.pickupcustomer}>
|
||||||
|
<span className="adcard-ic"><MdRestaurant /></span>
|
||||||
|
<span className="adcard-tx">{o.pickupcustomer}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="adcard-metrics">
|
||||||
|
<span className="adcard-m adcard-m-km" title="Trip distance">
|
||||||
|
<span className="adcard-ic"><MdStraighten /></span>
|
||||||
|
{parseFloat(o.actualkms || o.kms || 0).toFixed(1)} km
|
||||||
|
</span>
|
||||||
|
{estMeters !== null && (
|
||||||
|
<span className="adcard-m adcard-m-eta" title="Estimated distance to drop location">
|
||||||
|
<span className="adcard-ic"><MdMyLocation /></span>
|
||||||
|
{formatMeters(estMeters)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="adcard-list">{activeDeliveries.map(renderActiveDeliveryCard)}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActiveSection;
|
||||||
@@ -10729,6 +10729,169 @@
|
|||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── ActiveSection Delivery Cards ────────────────────────────── */
|
||||||
|
.dispatch-container .adcard-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 4px solid var(--ad-accent, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-color: var(--ad-accent, var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard.is-active {
|
||||||
|
border-color: var(--ad-accent, var(--accent));
|
||||||
|
box-shadow: 0 0 0 2px rgba(146, 85, 171, 0.15), var(--shadow-lg);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-customer {
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-rider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-addr {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-sub);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-ic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-tx {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-addr-tx {
|
||||||
|
white-space: normal;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-pickup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--kitchen);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-pickup .adcard-ic {
|
||||||
|
color: var(--kitchen);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-metrics {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .adcard-m {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
Mobile / app-like layout (≤ 768px). Purely a LAYOUT pass — no behaviour
|
Mobile / app-like layout (≤ 768px). Purely a LAYOUT pass — no behaviour
|
||||||
changes. On phones the side-by-side map + sidebar collapses into a single
|
changes. On phones the side-by-side map + sidebar collapses into a single
|
||||||
|
|||||||
@@ -52,8 +52,11 @@ import {
|
|||||||
MdTimer,
|
MdTimer,
|
||||||
MdCalendarToday,
|
MdCalendarToday,
|
||||||
MdInsights,
|
MdInsights,
|
||||||
MdRefresh
|
MdRefresh,
|
||||||
|
MdAttachMoney
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
|
import ProfitabilitySection from './ProfitabilitySection';
|
||||||
|
import ActiveSection from './ActiveSection';
|
||||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
||||||
import {
|
import {
|
||||||
STATUS_STYLES,
|
STATUS_STYLES,
|
||||||
@@ -61,7 +64,9 @@ import {
|
|||||||
FINAL_STATUSES,
|
FINAL_STATUSES,
|
||||||
SKIPPED_STATUSES,
|
SKIPPED_STATUSES,
|
||||||
STEP_PALETTE,
|
STEP_PALETTE,
|
||||||
stepColor
|
stepColor,
|
||||||
|
isActiveDelivery,
|
||||||
|
getActiveOrder
|
||||||
} from './dispatchShared';
|
} from './dispatchShared';
|
||||||
import CompareDataPanel from './CompareDataPanel';
|
import CompareDataPanel from './CompareDataPanel';
|
||||||
import './Dispatch.css';
|
import './Dispatch.css';
|
||||||
@@ -1329,10 +1334,11 @@ 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 { data: ridersLocationLogs } = useQuery({
|
const { data: ridersLocationLogs } = useQuery({
|
||||||
queryKey: [selectedAppLocationId, selectedDate, ''],
|
queryKey: [selectedAppLocationId, selectedDate, ''],
|
||||||
queryFn: fetchRidersLogs,
|
queryFn: fetchRidersLogs,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: RIDER_LOG_POLL_MS,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 5 * 1000,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false
|
refetchOnWindowFocus: false
|
||||||
@@ -1360,6 +1366,15 @@ const Dispatch = ({
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}, [ridersLocationLogs]);
|
}, [ridersLocationLogs]);
|
||||||
|
|
||||||
|
// Set of rider ids whose latest GPS log row is `active` (i.e. on the road
|
||||||
|
// right now). The "All Active Routes" view (viewMode === 'all') uses this to
|
||||||
|
// show ONLY currently-active riders — their cards, routes, drop markers and
|
||||||
|
// live bike markers — and hide everyone who is offline/idle for the slot.
|
||||||
|
const activeRiderIdSet = useMemo(
|
||||||
|
() => new Set(liveRiderLocations.filter((r) => r.status === 'active').map((r) => String(r.id))),
|
||||||
|
[liveRiderLocations]
|
||||||
|
);
|
||||||
// Default to the slot containing the current wall-clock time. Use a
|
// Default to the slot containing the current wall-clock time. Use a
|
||||||
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
|
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
|
||||||
// the current time falls outside every slot window (e.g. before 8 AM)
|
// the current time falls outside every slot window (e.g. before 8 AM)
|
||||||
@@ -1394,7 +1409,14 @@ const Dispatch = ({
|
|||||||
queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0],
|
queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0],
|
||||||
queryFn: fetchDeliveries,
|
queryFn: fetchDeliveries,
|
||||||
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
|
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
|
||||||
enabled: shouldFetchLive
|
enabled: shouldFetchLive,
|
||||||
|
// Order status (pending → delivered) only lives in this feed. Poll it in the
|
||||||
|
// Active view so a completed delivery drops out and the rider's NEXT active
|
||||||
|
// leg automatically becomes the one shown (card + route + flag) without a
|
||||||
|
// manual refresh. Other views don't need second-by-second order churn, so
|
||||||
|
// they refetch only on the usual triggers (date/slot/hub change, refocus).
|
||||||
|
refetchInterval: viewMode === 'all' ? 15_000 : false,
|
||||||
|
refetchIntervalInBackground: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-page through all results for the selected date.
|
// Auto-page through all results for the selected date.
|
||||||
@@ -1459,6 +1481,39 @@ const Dispatch = ({
|
|||||||
return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch);
|
return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch);
|
||||||
}, [liveRows, selectedBatch, selectedTimeField, BATCHES]);
|
}, [liveRows, selectedBatch, selectedTimeField, BATCHES]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const fixedCost = 166.67;
|
||||||
|
const totalCost = variableCost + fixedCost;
|
||||||
|
profit += (stats.revenue - totalCost);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return profit;
|
||||||
|
}, [liveRows, selectedTimeField, BATCHES]);
|
||||||
|
|
||||||
// Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes.
|
// Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes.
|
||||||
const liveData = useMemo(() => {
|
const liveData = useMemo(() => {
|
||||||
if (!shouldFetchLive) return null;
|
if (!shouldFetchLive) return null;
|
||||||
@@ -1650,6 +1705,63 @@ const Dispatch = ({
|
|||||||
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
|
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
|
||||||
: internalFocusedRider;
|
: internalFocusedRider;
|
||||||
|
|
||||||
|
// "All Active Routes" view scoping. This view is ORDER-CENTRIC: it shows ONLY
|
||||||
|
// riders who are live on GPS right now AND still have an in-progress order —
|
||||||
|
// their card in the sidebar, their single active-leg route line, and their
|
||||||
|
// live bike marker. Riders who are on GPS but have nothing left to deliver
|
||||||
|
// (everything delivered, or GPS-only with no orders) are intentionally
|
||||||
|
// excluded here. Every other view (By Location / By Zone / By Rider) is
|
||||||
|
// unchanged.
|
||||||
|
const isAllActiveView = viewMode === 'all';
|
||||||
|
// Orders belonging to GPS-active riders — the candidate pool for this view's
|
||||||
|
// list, drop set and auto-fit. Narrowed to in-progress orders below.
|
||||||
|
const allViewOrders = useMemo(
|
||||||
|
() => (isAllActiveView ? allOrders.filter((o) => activeRiderIdSet.has(String(o.rider_id))) : allOrders),
|
||||||
|
[isAllActiveView, allOrders, activeRiderIdSet]
|
||||||
|
);
|
||||||
|
// The single gate for the whole Active view: rider ids that are GPS-active AND
|
||||||
|
// currently have an in-progress order. Driving the sidebar list, routes,
|
||||||
|
// markers and map-fit off this one set guarantees they can never disagree
|
||||||
|
// about which riders are shown.
|
||||||
|
const activeOrderRiderIdSet = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
(isAllActiveView ? allViewOrders : [])
|
||||||
|
.filter(isActiveDelivery)
|
||||||
|
.map((o) => String(o.rider_id))
|
||||||
|
),
|
||||||
|
[isAllActiveView, allViewOrders]
|
||||||
|
);
|
||||||
|
// The riders we render in "All Active Routes": GPS-active riders that still
|
||||||
|
// have an active order. Every other view is unchanged.
|
||||||
|
const visibleRiders = useMemo(
|
||||||
|
() =>
|
||||||
|
isAllActiveView
|
||||||
|
? riders.filter((r) => activeOrderRiderIdSet.has(String(r.id)))
|
||||||
|
: riders,
|
||||||
|
[isAllActiveView, riders, activeOrderRiderIdSet]
|
||||||
|
);
|
||||||
|
// Live GPS coordinates of exactly the riders this view shows (active + has an
|
||||||
|
// order), fed to MapController's auto-fit so the map frames what's rendered.
|
||||||
|
const allViewLivePoints = useMemo(
|
||||||
|
() =>
|
||||||
|
isAllActiveView
|
||||||
|
? liveRiderLocations
|
||||||
|
.filter((r) => r.status === 'active' && activeOrderRiderIdSet.has(String(r.id)))
|
||||||
|
.map((r) => [r.lat, r.lon])
|
||||||
|
: [],
|
||||||
|
[isAllActiveView, liveRiderLocations, activeOrderRiderIdSet]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count of in-progress deliveries shown in the Active view list. Drives the
|
||||||
|
// sidebar header visibility — when the active fleet has nothing in progress,
|
||||||
|
// the header (RIDER DISPATCH title + Active Fleet badge + order/rider tiles)
|
||||||
|
// is hidden so the "No active deliveries" empty state stands on its own.
|
||||||
|
const activeDeliveryCount = useMemo(
|
||||||
|
() => (isAllActiveView ? allViewOrders.filter(isActiveDelivery).length : 0),
|
||||||
|
[isAllActiveView, allViewOrders]
|
||||||
|
);
|
||||||
|
|
||||||
// Per-rider canvas renderer for the actual (right) map in Compare mode.
|
// Per-rider canvas renderer for the actual (right) map in Compare mode.
|
||||||
// Single setter used by every interactive site in the UI. In uncontrolled mode it
|
// Single setter used by every interactive site in the UI. In uncontrolled mode it
|
||||||
// updates local state; in controlled mode it only notifies the parent.
|
// updates local state; in controlled mode it only notifies the parent.
|
||||||
@@ -1728,6 +1840,21 @@ const Dispatch = ({
|
|||||||
label: 'Focused Kitchen'
|
label: 'Focused Kitchen'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// "All Active Routes": the header must reflect exactly what the list/map
|
||||||
|
// shows — the in-progress orders and the riders working them — NOT the whole
|
||||||
|
// day's totals. We count only active deliveries (and `visibleRiders`, which
|
||||||
|
// 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);
|
||||||
|
return {
|
||||||
|
orders: activeOrders.length,
|
||||||
|
riders: visibleRiders.length,
|
||||||
|
km: activeOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
|
||||||
|
profit: activeOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
|
||||||
|
label: 'Active Fleet'
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
orders: stats.totalOrders,
|
orders: stats.totalOrders,
|
||||||
riders: stats.totalRiders,
|
riders: stats.totalRiders,
|
||||||
@@ -1735,7 +1862,7 @@ const Dispatch = ({
|
|||||||
profit: stats.totalProfit,
|
profit: stats.totalProfit,
|
||||||
label: 'Total Fleet'
|
label: 'Total Fleet'
|
||||||
};
|
};
|
||||||
}, [focusedRider, focusedKitchen, stats]);
|
}, [focusedRider, focusedKitchen, isAllActiveView, allViewOrders, visibleRiders, stats]);
|
||||||
|
|
||||||
// List of deliveryids tied to the focused rider's orders — used to drive the
|
// List of deliveryids tied to the focused rider's orders — used to drive the
|
||||||
// batched per-delivery GPS log fetch for Compare mode. Deduped; ignores rows
|
// batched per-delivery GPS log fetch for Compare mode. Deduped; ignores rows
|
||||||
@@ -2442,6 +2569,7 @@ const Dispatch = ({
|
|||||||
return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`;
|
return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail.
|
// Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail.
|
||||||
const renderRiderCard = (r, i) => {
|
const renderRiderCard = (r, i) => {
|
||||||
const total = r.orders.length;
|
const total = r.orders.length;
|
||||||
@@ -3275,7 +3403,21 @@ const Dispatch = ({
|
|||||||
onClick={() => { logger.info('View mode changed: By Zone'); setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
|
onClick={() => { logger.info('View mode changed: By Zone'); setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
|
||||||
><span className="sbt-icon"><MdMap /></span> By Zone</button>
|
><span className="sbt-icon"><MdMap /></span> By Zone</button>
|
||||||
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Rider'); setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
|
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Rider'); setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
|
||||||
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span> All Routes</button>
|
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Active Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span>Active</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`sbt sbt-profitability ${viewMode === 'profitability' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
logger.info('View mode changed: Profitability');
|
||||||
|
setViewMode('profitability');
|
||||||
|
handleRiderFocus(null);
|
||||||
|
setFocusedKitchen(null);
|
||||||
|
setFocusedZone(null);
|
||||||
|
}}
|
||||||
|
title="View overall and rider profitability"
|
||||||
|
>
|
||||||
|
<span className="sbt-icon"><MdAttachMoney /></span> Profitability
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`}
|
className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`}
|
||||||
@@ -3483,7 +3625,9 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === 'rider-info' ? (
|
{viewMode === 'profitability' ? (
|
||||||
|
<ProfitabilitySection riders={riders} handleRiderFocus={handleRiderFocus} focusedRider={focusedRider} totalDailyProfit={totalDailyProfit} />
|
||||||
|
) : viewMode === 'rider-info' ? (
|
||||||
<div className="rider-info-mode">
|
<div className="rider-info-mode">
|
||||||
<div className="ri-sidebar">
|
<div className="ri-sidebar">
|
||||||
<div className="ri-sb-head">
|
<div className="ri-sb-head">
|
||||||
@@ -4367,11 +4511,14 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div id="riders-panel">
|
<div id="riders-panel">
|
||||||
<div className="ph">{
|
{!(isAllActiveView && activeDeliveryCount === 0) && (
|
||||||
viewMode === 'zones' ? 'Zone dispatch' :
|
<div className="ph">{
|
||||||
viewMode === 'kitchens' ? 'Kitchen dispatch' :
|
viewMode === 'zones' ? 'Zone dispatch' :
|
||||||
'Rider dispatch'
|
viewMode === 'kitchens' ? 'Kitchen dispatch' :
|
||||||
}</div>
|
isAllActiveView ? 'Active rider dispatch' :
|
||||||
|
'Rider dispatch'
|
||||||
|
}</div>
|
||||||
|
)}
|
||||||
<div id="rider-cards">
|
<div id="rider-cards">
|
||||||
{allOrders.length === 0 && !liveIsFetching ? (
|
{allOrders.length === 0 && !liveIsFetching ? (
|
||||||
(() => {
|
(() => {
|
||||||
@@ -4495,8 +4642,19 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
) : isAllActiveView ? (
|
||||||
|
<ActiveSection
|
||||||
|
visibleRiders={visibleRiders}
|
||||||
|
riders={riders}
|
||||||
|
focusedStop={focusedStop}
|
||||||
|
handleRiderFocus={handleRiderFocus}
|
||||||
|
setFocusedStop={setFocusedStop}
|
||||||
|
calculateEstMeters={calculateEstMeters}
|
||||||
|
getRiderColor={getRiderColor}
|
||||||
|
formatMeters={formatMeters}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
riders.map(renderRiderCard)
|
visibleRiders.map(renderRiderCard)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4522,7 +4680,7 @@ const Dispatch = ({
|
|||||||
<ZoomControl position="bottomright" />
|
<ZoomControl position="bottomright" />
|
||||||
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
|
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
|
||||||
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
|
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
|
||||||
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} kitchens={kitchens} locationKey={selectedAppLocationId} />
|
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
|
||||||
{kitchens
|
{kitchens
|
||||||
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
||||||
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
|
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
|
||||||
|
|||||||
1071
src/pages/nearle/dispatch/ProfitabilitySection.css
Normal file
1071
src/pages/nearle/dispatch/ProfitabilitySection.css
Normal file
File diff suppressed because it is too large
Load Diff
628
src/pages/nearle/dispatch/ProfitabilitySection.js
Normal file
628
src/pages/nearle/dispatch/ProfitabilitySection.js
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {
|
||||||
|
MdTrendingUp,
|
||||||
|
MdTrendingDown,
|
||||||
|
MdExpandMore,
|
||||||
|
MdReceipt,
|
||||||
|
MdPayments,
|
||||||
|
MdRoute,
|
||||||
|
MdLocationOn,
|
||||||
|
MdBarChart,
|
||||||
|
MdPeopleAlt,
|
||||||
|
MdSearch
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import './ProfitabilitySection.css';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Constants
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Revenue rule: ₹30 base for ≤8 km, ₹6/km beyond. */
|
||||||
|
const BASE_REVENUE = 30;
|
||||||
|
const BASE_KM_LIMIT = 8;
|
||||||
|
const EXTRA_RATE_KM = 6;
|
||||||
|
|
||||||
|
/** Fixed salary cost sliced per slot (₹5000 / 30 days / 1 slot). */
|
||||||
|
const FIXED_COST_PER_SLOT = 166.67;
|
||||||
|
|
||||||
|
/** Variable fuel / wear cost per km. */
|
||||||
|
const VARIABLE_RATE_KM = 2.5;
|
||||||
|
|
||||||
|
/** Status display config keyed by normalised status string. */
|
||||||
|
const STATUS_MAP = {
|
||||||
|
delivered: { label: 'Delivered', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
|
||||||
|
active: { label: 'Active', color: '#0ea5e9', bg: '#f0f9ff', border: '#bae6fd' },
|
||||||
|
picked: { label: 'Picked up', color: '#8b5cf6', bg: '#f5f3ff', border: '#ddd6fe' },
|
||||||
|
assigned: { label: 'Assigned', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
|
||||||
|
cancelled: { label: 'Cancelled', color: '#ef4444', bg: '#fef2f2', border: '#fca5a5' },
|
||||||
|
skipped: { label: 'Skipped', color: '#f97316', bg: '#fff7ed', border: '#ffedd5' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STATUS = STATUS_MAP.assigned;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Pure helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getStatusConfig(raw) {
|
||||||
|
return STATUS_MAP[String(raw ?? '').toLowerCase()] ?? DEFAULT_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderRevenue(order) {
|
||||||
|
const km = parseFloat(order.kms ?? order.actualkms ?? 0);
|
||||||
|
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRiderMetrics(rider) {
|
||||||
|
const orders = rider.orders ?? [];
|
||||||
|
let revenue = 0;
|
||||||
|
let kms = 0;
|
||||||
|
|
||||||
|
for (const o of orders) {
|
||||||
|
revenue += orderRevenue(o);
|
||||||
|
kms += parseFloat(o.kms ?? o.actualkms ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const varCost = kms * VARIABLE_RATE_KM;
|
||||||
|
const fixedCost = FIXED_COST_PER_SLOT;
|
||||||
|
const totalCost = varCost + fixedCost;
|
||||||
|
const net = revenue - totalCost;
|
||||||
|
const margin = revenue > 0 ? (net / revenue) * 100 : 0;
|
||||||
|
|
||||||
|
return { revenue, kms, varCost, fixedCost, totalCost, net, margin };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rupees(v, decimals = 0) {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return `₹${parseFloat(v).toFixed(decimals)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riderInitials(name) {
|
||||||
|
if (!name) return '?';
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || '?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Sub-components
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Coloured pill with a status dot. */
|
||||||
|
function OrderStatusPill({ status }) {
|
||||||
|
const cfg = getStatusConfig(status);
|
||||||
|
return (
|
||||||
|
<span className="order-status-pill" style={{ background: cfg.bg, color: cfg.color, borderColor: cfg.border }}>
|
||||||
|
<span className="order-status-dot" aria-hidden="true" />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer name + phone + location stacked cell. */
|
||||||
|
function CustomerCell({ order }) {
|
||||||
|
const name = order.customername ?? order.customer_name ?? order.deliverycustomer ?? order.pickupcustomer ?? 'Unknown customer';
|
||||||
|
const phone = order.customerphone ?? order.phone ?? order.mobile ?? '';
|
||||||
|
const location = order.locationname ?? order.kitchenname ?? order.kitchenName ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="customer-name" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{phone && <div className="customer-phone">{phone}</div>}
|
||||||
|
{location && (
|
||||||
|
<div className="customer-location">
|
||||||
|
<MdLocationOn size={12} aria-hidden="true" />
|
||||||
|
{location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orders table inside an expanded rider card. */
|
||||||
|
function OrdersBreakdownTable({ orders, getRevenue }) {
|
||||||
|
if (!orders?.length) {
|
||||||
|
return (
|
||||||
|
<div className="orders-empty-state" role="status">
|
||||||
|
<MdReceipt size={24} aria-hidden="true" />
|
||||||
|
<p className="orders-empty-message">No orders assigned to this rider yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="orders-table-container">
|
||||||
|
<table className="orders-table" role="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Customer</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Distance</th>
|
||||||
|
<th scope="col" style={{ textAlign: 'right' }}>
|
||||||
|
Revenue
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((order, idx) => {
|
||||||
|
const km = parseFloat(order.kms ?? order.actualkms ?? 0);
|
||||||
|
const rev = getRevenue(order);
|
||||||
|
return (
|
||||||
|
<tr key={order.orderid ?? idx}>
|
||||||
|
<td>
|
||||||
|
<CustomerCell order={order} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<OrderStatusPill status={order.status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="distance-value">{km.toFixed(1)}</span>
|
||||||
|
<span className="distance-unit">km</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<span className="revenue-amount">{rupees(rev)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expanded cost breakdown + orders for one rider. */
|
||||||
|
function RiderDetailPanel({ rider, metrics }) {
|
||||||
|
const { varCost, fixedCost, kms, net, margin } = metrics;
|
||||||
|
const isProfit = net >= 0;
|
||||||
|
|
||||||
|
const costTiles = [
|
||||||
|
{
|
||||||
|
icon: <MdPayments size={16} />,
|
||||||
|
iconClass: 'cost-tile-icon--fixed',
|
||||||
|
label: 'Fixed cost',
|
||||||
|
amount: rupees(fixedCost, 2),
|
||||||
|
amountClass: '',
|
||||||
|
footnote: 'Salary (per slot)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MdRoute size={16} />,
|
||||||
|
iconClass: 'cost-tile-icon--variable',
|
||||||
|
label: 'Variable cost',
|
||||||
|
amount: rupees(varCost, 2),
|
||||||
|
amountClass: '',
|
||||||
|
footnote: `${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />,
|
||||||
|
iconClass: isProfit ? 'cost-tile-icon--profit' : 'cost-tile-icon--loss',
|
||||||
|
label: isProfit ? 'Net profit' : 'Net loss',
|
||||||
|
amount: `${isProfit ? '+' : ''}${rupees(net, 2)}`,
|
||||||
|
amountClass: isProfit ? 'cost-tile-amount--profit' : 'cost-tile-amount--loss',
|
||||||
|
footnote: `${Math.abs(margin).toFixed(0)}% margin`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rider-detail-panel" aria-label="Rider cost breakdown">
|
||||||
|
<div className="rider-detail-inner">
|
||||||
|
{/* Cost breakdown tiles */}
|
||||||
|
<div className="cost-breakdown-grid">
|
||||||
|
{costTiles.map((tile) => (
|
||||||
|
<div className="cost-tile" key={tile.label}>
|
||||||
|
<div className={`cost-tile-icon ${tile.iconClass}`} aria-hidden="true">
|
||||||
|
{tile.icon}
|
||||||
|
</div>
|
||||||
|
<div className="cost-tile-content">
|
||||||
|
<span className="cost-tile-label">{tile.label}</span>
|
||||||
|
<span className={`cost-tile-amount ${tile.amountClass}`}>{tile.amount}</span>
|
||||||
|
<span className="cost-tile-footnote">{tile.footnote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue breakdown table */}
|
||||||
|
<div className="revenue-breakdown">
|
||||||
|
<div className="revenue-breakdown-header">
|
||||||
|
<div className="revenue-breakdown-title">
|
||||||
|
<MdReceipt size={14} aria-hidden="true" />
|
||||||
|
Revenue Breakdown
|
||||||
|
</div>
|
||||||
|
<span className="revenue-breakdown-count">
|
||||||
|
{rider.orders?.length ?? 0} order{(rider.orders?.length ?? 0) !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<OrdersBreakdownTable orders={rider.orders} getRevenue={orderRevenue} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single expandable rider profitability card. */
|
||||||
|
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
|
||||||
|
const { revenue, kms, totalCost, net, margin } = metrics;
|
||||||
|
const isProfit = net >= 0;
|
||||||
|
const marginBarWidth = `${clamp(Math.abs(margin), 0, 100)}%`;
|
||||||
|
const orderCount = rider.orders?.length ?? 0;
|
||||||
|
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
|
||||||
|
|
||||||
|
const cardClasses = [
|
||||||
|
'rider-profitability-card',
|
||||||
|
isProfit ? 'rider-profitability-card--profitable' : 'rider-profitability-card--unprofitable',
|
||||||
|
isFocused ? 'rider-profitability-card--selected' : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
function handleActivate() {
|
||||||
|
onToggle(rider.id);
|
||||||
|
if (!isFocused && onFocus) onFocus(rider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cardClasses} aria-label={`Rider: ${name}`}>
|
||||||
|
{/* Clickable header row */}
|
||||||
|
<div
|
||||||
|
className="rider-card-header"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={handleActivate}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleActivate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Rider identity */}
|
||||||
|
<div className="rider-avatar" aria-hidden="true">
|
||||||
|
{riderInitials(name)}
|
||||||
|
</div>
|
||||||
|
<div className="rider-identity">
|
||||||
|
<div className="rider-name" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="rider-order-summary">
|
||||||
|
{orderCount} order{orderCount !== 1 ? 's' : ''}
|
||||||
|
<span className="rider-order-summary-separator">·</span>
|
||||||
|
{kms.toFixed(1)} km total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary metrics — hidden on mobile via CSS */}
|
||||||
|
<div className="rider-metrics-row" aria-hidden="true">
|
||||||
|
<div className="rider-metric">
|
||||||
|
<span className="rider-metric-label">Revenue</span>
|
||||||
|
<span className="rider-metric-amount rider-metric-amount--revenue">{rupees(revenue)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rider-metric">
|
||||||
|
<span className="rider-metric-label">Cost</span>
|
||||||
|
<span className="rider-metric-amount rider-metric-amount--cost">{rupees(totalCost)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rider-net-profit-column">
|
||||||
|
<div className="rider-metric">
|
||||||
|
<span className="rider-metric-label">Net</span>
|
||||||
|
<span className={`rider-metric-amount ${isProfit ? 'rider-metric-amount--net-profit' : 'rider-metric-amount--net-loss'}`}>
|
||||||
|
{isProfit ? '+' : ''}
|
||||||
|
{rupees(net)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rider-margin-bar">
|
||||||
|
<div
|
||||||
|
className={`rider-margin-bar-fill ${isProfit ? 'rider-margin-bar-fill--profit' : 'rider-margin-bar-fill--loss'}`}
|
||||||
|
style={{ width: marginBarWidth }}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand chevron */}
|
||||||
|
<div className={`rider-expand-toggle ${isExpanded ? 'rider-expand-toggle--open' : ''}`} aria-hidden="true">
|
||||||
|
<MdExpandMore size={22} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded detail panel */}
|
||||||
|
{isExpanded && <RiderDetailPanel rider={rider} metrics={metrics} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Main export
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProfitabilitySection
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* riders — Array of rider objects with .orders[]
|
||||||
|
* totalDailyProfit — Number: daily aggregate across all slots
|
||||||
|
* focusedRider — Rider object (or null) synced with map
|
||||||
|
* handleRiderFocus — (rider) => void called when a card is clicked
|
||||||
|
*/
|
||||||
|
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) => {
|
||||||
|
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Enrich riders with computed metrics
|
||||||
|
const enriched = useMemo(() => riders.map((r) => ({ ...r, _m: calcRiderMetrics(r) })), [riders]);
|
||||||
|
|
||||||
|
// Aggregate overall slot totals (pre-filtered for BI consistency)
|
||||||
|
const slotRevenue = enriched.reduce((s, r) => s + r._m.revenue, 0);
|
||||||
|
const slotCost = enriched.reduce((s, r) => s + r._m.totalCost, 0);
|
||||||
|
const slotNet = enriched.reduce((s, r) => s + r._m.net, 0);
|
||||||
|
const profitCount = enriched.filter((r) => r._m.net >= 0).length;
|
||||||
|
const lossCount = enriched.length - profitCount;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Sort riders based on selected sortMode
|
||||||
|
const sortedAndFiltered = useMemo(() => {
|
||||||
|
const list = [...filtered];
|
||||||
|
if (sortMode === 'profit-asc') {
|
||||||
|
list.sort((a, b) => a._m.net - b._m.net);
|
||||||
|
} else if (sortMode === 'profit-desc') {
|
||||||
|
list.sort((a, b) => b._m.net - a._m.net);
|
||||||
|
} else if (sortMode === 'name-asc') {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const nameA = (a.riderName ?? a.username ?? `Rider #${a.id}`).toLowerCase();
|
||||||
|
const nameB = (b.riderName ?? b.username ?? `Rider #${b.id}`).toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
} else if (sortMode === 'orders-desc') {
|
||||||
|
list.sort((a, b) => (b.orders?.length ?? 0) - (a.orders?.length ?? 0));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [filtered, sortMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profitability-dashboard" role="region" aria-label="Profitability overview">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="profitability-header">
|
||||||
|
<div className="profitability-header-left">
|
||||||
|
<div className="profitability-header-icon" aria-hidden="true">
|
||||||
|
<MdBarChart size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="profitability-header-title">Profitability Overview</h2>
|
||||||
|
<p className="profitability-header-subtitle">
|
||||||
|
<span>
|
||||||
|
{enriched.length} rider{enriched.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="profitability-header-dot" />
|
||||||
|
<span style={{ color: 'var(--profit-green)' }}>{profitCount} profitable</span>
|
||||||
|
<span className="profitability-header-dot" />
|
||||||
|
<span style={{ color: 'var(--loss-red)' }}>{lossCount} at loss</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profitability-kpi-group">
|
||||||
|
{/* Daily profit / loss */}
|
||||||
|
<div className={`profitability-kpi-chip ${dailyIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
|
||||||
|
<div className="profitability-kpi-chip-icon" aria-hidden="true">
|
||||||
|
{dailyIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
|
||||||
|
</div>
|
||||||
|
<div className="profitability-kpi-chip-content">
|
||||||
|
<span className="profitability-kpi-chip-label">Daily {dailyIsProfit ? 'profit' : 'loss'}</span>
|
||||||
|
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(totalDailyProfit))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slot profit / loss */}
|
||||||
|
<div className={`profitability-kpi-chip ${slotIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
|
||||||
|
<div className="profitability-kpi-chip-icon" aria-hidden="true">
|
||||||
|
{slotIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
|
||||||
|
</div>
|
||||||
|
<div className="profitability-kpi-chip-content">
|
||||||
|
<span className="profitability-kpi-chip-label">Slot {slotIsProfit ? 'profit' : 'loss'}</span>
|
||||||
|
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(slotNet))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Summary statistics row ── */}
|
||||||
|
<div className="profitability-summary-row" role="group" aria-label="Slot summary">
|
||||||
|
<div className="profitability-summary-card profitability-summary-card--primary">
|
||||||
|
<span className="profitability-summary-label">Riders Active</span>
|
||||||
|
<span className="profitability-summary-value">{enriched.length}</span>
|
||||||
|
<span className="profitability-summary-detail">
|
||||||
|
{profitCount} in profit · {lossCount} at loss
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="profitability-summary-card profitability-summary-card--primary">
|
||||||
|
<span className="profitability-summary-label">Slot Revenue</span>
|
||||||
|
<span className="profitability-summary-value">{rupees(slotRevenue)}</span>
|
||||||
|
<span className="profitability-summary-detail">From {enriched.reduce((s, r) => s + (r.orders?.length ?? 0), 0)} orders</span>
|
||||||
|
</div>
|
||||||
|
<div className="profitability-summary-card profitability-summary-card--loss">
|
||||||
|
<span className="profitability-summary-label">Slot Cost</span>
|
||||||
|
<span className="profitability-summary-value profitability-summary-value--loss">{rupees(slotCost)}</span>
|
||||||
|
<span className="profitability-summary-detail">Fixed + variable</span>
|
||||||
|
</div>
|
||||||
|
<div className="profitability-summary-card profitability-summary-card--profit">
|
||||||
|
<span className="profitability-summary-label">Slot Net</span>
|
||||||
|
<span
|
||||||
|
className={`profitability-summary-value ${
|
||||||
|
slotIsProfit ? 'profitability-summary-value--profit' : 'profitability-summary-value--loss'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{slotIsProfit ? '+' : ''}
|
||||||
|
{rupees(slotNet)}
|
||||||
|
</span>
|
||||||
|
<span className="profitability-summary-detail">{slotRevenue > 0 ? ((slotNet / slotRevenue) * 100).toFixed(0) : 0}% margin</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* ── Rider feed ── */}
|
||||||
|
<div className="rider-profitability-feed" role="list" aria-label="Rider cards">
|
||||||
|
<div className="rider-feed-heading" aria-hidden="true">
|
||||||
|
Riders List
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedAndFiltered.length === 0 ? (
|
||||||
|
<div className="profitability-empty-state" role="status">
|
||||||
|
<MdPeopleAlt size={32} aria-hidden="true" />
|
||||||
|
<p className="profitability-empty-message">No riders matching the filters.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedAndFiltered.map((rider) => (
|
||||||
|
<div key={rider.id} role="listitem">
|
||||||
|
<RiderProfitabilityCard
|
||||||
|
rider={rider}
|
||||||
|
metrics={rider._m}
|
||||||
|
isExpanded={!!expanded[rider.id]}
|
||||||
|
isFocused={focusedRider?.id === rider.id}
|
||||||
|
onToggle={toggleRider}
|
||||||
|
onFocus={handleRiderFocus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderStatusPill.propTypes = {
|
||||||
|
status: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerCell.propTypes = {
|
||||||
|
order: PropTypes.object.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
OrdersBreakdownTable.propTypes = {
|
||||||
|
orders: PropTypes.array,
|
||||||
|
getRevenue: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
RiderDetailPanel.propTypes = {
|
||||||
|
rider: PropTypes.object.isRequired,
|
||||||
|
metrics: PropTypes.shape({
|
||||||
|
varCost: PropTypes.number.isRequired,
|
||||||
|
fixedCost: PropTypes.number.isRequired,
|
||||||
|
kms: PropTypes.number.isRequired,
|
||||||
|
net: PropTypes.number.isRequired,
|
||||||
|
margin: PropTypes.number.isRequired
|
||||||
|
}).isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
RiderProfitabilityCard.propTypes = {
|
||||||
|
rider: PropTypes.object.isRequired,
|
||||||
|
metrics: PropTypes.shape({
|
||||||
|
revenue: PropTypes.number.isRequired,
|
||||||
|
kms: PropTypes.number.isRequired,
|
||||||
|
totalCost: PropTypes.number.isRequired,
|
||||||
|
net: PropTypes.number.isRequired,
|
||||||
|
margin: PropTypes.number.isRequired
|
||||||
|
}).isRequired,
|
||||||
|
isExpanded: PropTypes.bool.isRequired,
|
||||||
|
isFocused: PropTypes.bool,
|
||||||
|
onToggle: PropTypes.func.isRequired,
|
||||||
|
onFocus: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
ProfitabilitySection.propTypes = {
|
||||||
|
riders: PropTypes.array,
|
||||||
|
totalDailyProfit: PropTypes.number,
|
||||||
|
focusedRider: PropTypes.object,
|
||||||
|
handleRiderFocus: PropTypes.func
|
||||||
|
};
|
||||||
@@ -6,14 +6,14 @@
|
|||||||
// Status palette — single source of truth for the status pill colors
|
// Status palette — single source of truth for the status pill colors
|
||||||
// rendered on rider cards, order rows, step lists, and tooltips.
|
// rendered on rider cards, order rows, step lists, and tooltips.
|
||||||
export const STATUS_STYLES = {
|
export const STATUS_STYLES = {
|
||||||
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
|
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
|
||||||
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
|
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
|
||||||
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
|
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
|
||||||
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
|
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
|
||||||
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
|
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
|
||||||
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
|
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
|
||||||
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
|
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
|
||||||
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
|
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
|
||||||
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
|
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,3 +63,25 @@ export const ordinal = (n) => {
|
|||||||
const v = n % 100;
|
const v = n % 100;
|
||||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// An order is "active" (currently in progress) when it's neither completed
|
||||||
|
// (delivered) nor skipped/cancelled. The Active view uses this to collapse a
|
||||||
|
// rider down to the single delivery they're working on right now.
|
||||||
|
export const isActiveDelivery = (o) => {
|
||||||
|
const s = String(o?.orderstatus || '').toLowerCase();
|
||||||
|
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A rider's single in-progress delivery: the first non-final, non-skipped
|
||||||
|
// stop in (trip, step) order. Returns null when the rider has nothing active
|
||||||
|
// (everything delivered/cancelled, or GPS-only with no orders).
|
||||||
|
export const getActiveOrder = (orders) => {
|
||||||
|
if (!Array.isArray(orders) || !orders.length) return null;
|
||||||
|
const sorted = [...orders].sort((a, b) => {
|
||||||
|
const tA = a.trip_number || 1;
|
||||||
|
const tB = b.trip_number || 1;
|
||||||
|
if (tA !== tB) return tA - tB;
|
||||||
|
return (a.step || 0) - (b.step || 0);
|
||||||
|
});
|
||||||
|
return sorted.find(isActiveDelivery) || null;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user