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 - + + + + + + +
+ Sort: + +
+ + + {/* ── Rider feed ── */} +
+ + + {sortedAndFiltered.length === 0 ? ( +
+
+ ) : ( + sortedAndFiltered.map((rider) => ( +
+ +
+ )) + )} +
+ + ); +} + +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 +}; diff --git a/src/pages/nearle/dispatch/dispatchShared.js b/src/pages/nearle/dispatch/dispatchShared.js index dfdc118..3d8cf0b 100644 --- a/src/pages/nearle/dispatch/dispatchShared.js +++ b/src/pages/nearle/dispatch/dispatchShared.js @@ -6,14 +6,14 @@ // Status palette — single source of truth for the status pill colors // rendered on rider cards, order rows, step lists, and tooltips. export const STATUS_STYLES = { - created: { label: 'Created', bg: '#3b82f6', fg: '#fff' }, - pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' }, - accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' }, - arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' }, - picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' }, - active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' }, + created: { label: 'Created', bg: '#3b82f6', fg: '#fff' }, + pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' }, + accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' }, + arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' }, + picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' }, + active: { label: 'Active', bg: '#0ea5e9', 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' } }; @@ -63,3 +63,25 @@ export const ordinal = (n) => { const v = n % 100; 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; +};