diff --git a/src/pages/nearle/dispatch/ActiveSection.js b/src/pages/nearle/dispatch/ActiveSection.js
new file mode 100644
index 0000000..6976465
--- /dev/null
+++ b/src/pages/nearle/dispatch/ActiveSection.js
@@ -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 (
+
+
+
+
+
No active deliveries
+
+ No deliveries are currently in progress for this slot
+
+
+ );
+ }
+
+ 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 (
+ {
+ if (rider) handleRiderFocus(rider);
+ if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
+ }}
+ >
+
+
{initials}
+
+
{customer}
+
+
+ {riderName}
+
+
+
+ {statusStyle.label}
+
+
+
+ {dropArea && (
+
+
+ {dropArea}
+
+ )}
+
+
+ {o.pickupcustomer ? (
+
+
+ {o.pickupcustomer}
+
+ ) : (
+
+ )}
+
+
+
+ {parseFloat(o.actualkms || o.kms || 0).toFixed(1)} km
+
+ {estMeters !== null && (
+
+
+ {formatMeters(estMeters)}
+
+ )}
+
+
+
+ );
+ };
+
+ return {activeDeliveries.map(renderActiveDeliveryCard)}
;
+};
+
+export default ActiveSection;
diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css
index b6d2be1..9a32475 100644
--- a/src/pages/nearle/dispatch/Dispatch.css
+++ b/src/pages/nearle/dispatch/Dispatch.css
@@ -10729,6 +10729,169 @@
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
changes. On phones the side-by-side map + sidebar collapses into a single
diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js
index c89add7..913583e 100644
--- a/src/pages/nearle/dispatch/Dispatch.js
+++ b/src/pages/nearle/dispatch/Dispatch.js
@@ -52,8 +52,11 @@ import {
MdTimer,
MdCalendarToday,
MdInsights,
- MdRefresh
+ MdRefresh,
+ MdAttachMoney
} from 'react-icons/md';
+import ProfitabilitySection from './ProfitabilitySection';
+import ActiveSection from './ActiveSection';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
import {
STATUS_STYLES,
@@ -61,7 +64,9 @@ import {
FINAL_STATUSES,
SKIPPED_STATUSES,
STEP_PALETTE,
- stepColor
+ stepColor,
+ isActiveDelivery,
+ getActiveOrder
} from './dispatchShared';
import CompareDataPanel from './CompareDataPanel';
import './Dispatch.css';
@@ -1329,10 +1334,11 @@ const Dispatch = ({
// hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page.
+ const RIDER_LOG_POLL_MS = viewMode === 'all' ? 1_000 : 15_000;
const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''],
queryFn: fetchRidersLogs,
- refetchInterval: 15_000,
+ refetchInterval: RIDER_LOG_POLL_MS,
refetchIntervalInBackground: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false
@@ -1360,6 +1366,15 @@ const Dispatch = ({
})
.filter(Boolean);
}, [ridersLocationLogs]);
+
+ // Set of rider ids whose latest GPS log row is `active` (i.e. on the road
+ // right now). The "All Active Routes" view (viewMode === 'all') uses this to
+ // show ONLY currently-active riders — their cards, routes, drop markers and
+ // live bike markers — and hide everyone who is offline/idle for the slot.
+ const activeRiderIdSet = useMemo(
+ () => new Set(liveRiderLocations.filter((r) => r.status === 'active').map((r) => String(r.id))),
+ [liveRiderLocations]
+ );
// Default to the slot containing the current wall-clock time. Use a
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
// the current time falls outside every slot window (e.g. before 8 AM)
@@ -1394,7 +1409,14 @@ const Dispatch = ({
queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0],
queryFn: fetchDeliveries,
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.
@@ -1459,6 +1481,39 @@ const Dispatch = ({
return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch);
}, [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.
const liveData = useMemo(() => {
if (!shouldFetchLive) return null;
@@ -1650,6 +1705,63 @@ const Dispatch = ({
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: 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.
// 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.
@@ -1728,6 +1840,21 @@ const Dispatch = ({
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 {
orders: stats.totalOrders,
riders: stats.totalRiders,
@@ -1735,7 +1862,7 @@ const Dispatch = ({
profit: stats.totalProfit,
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
// 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`;
};
+
// Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail.
const renderRiderCard = (r, i) => {
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); }}
> By Zone
-
+
+