diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 79611eb..f4d4e7b 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -9513,6 +9513,58 @@ cursor: wait; } +/* ─── Auto-refresh indicator (analysis section). + A pill that sits next to the manual refresh button, telling the + operator the panel is polling and at what cadence. The dot pulses + green when idle, amber while a refresh is in flight. ─────────── */ +.dispatch-container .da-detail-actions { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.dispatch-container .da-live-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: #166534; + background: #ecfdf5; + border: 1px solid #a7f3d0; + padding: 4px 9px 4px 8px; + border-radius: 999px; + user-select: none; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.dispatch-container .da-live-tag.is-active { + color: #92400e; + background: #fffbeb; + border-color: #fde68a; +} + +.dispatch-container .da-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18); + animation: da-live-pulse 1.8s ease-in-out infinite; +} + +.dispatch-container .da-live-tag.is-active .da-live-dot { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22); + animation: da-live-pulse 0.9s ease-in-out infinite; +} + +@keyframes da-live-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} + .dispatch-container .da-metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -9872,6 +9924,227 @@ border-radius: 12px; } +/* Clickable chip variant — used for the "last position" GPS chip that + opens the in-app Leaflet position modal. Keeps the same shell as + .da-chip but reads as a link on hover. Also serves as a button reset + for the - + + + + +
+ {displayOrders.map((o, idx) => { + const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim(); + const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; + prevKitchenKey = kitchenKey; + const isStopActive = focusedStop && focusedStop.orderid === o.orderid; + const isGoingOn = activeOrderId && o.orderid === activeOrderId; + const lat = parseFloat(o.droplat || o.deliverylat); + const lon = parseFloat(o.droplon || o.deliverylong); + const canFocus = Number.isFinite(lat) && Number.isFinite(lon); + const statusStyle = getStatusStyle(o.orderstatus); + const profit = parseFloat(o.profit || 0); + const isLoss = profit < 0; + const estMeters = calculateEstMeters(focusedRider.id, o); + // Badge number is ALWAYS the planned step (o.step) + // — the same number regardless of sort mode. Time + // mode only reorders the cards; the badge keeps + // its dispatched identity so operators can still + // map a card on screen back to "step 3 of the + // planned route" without mental translation. + const displayNum = o.step || idx + 1; + // "Not yet delivered" indicator for time mode — we + // pushed these rows to the end via MAX_SAFE_INTEGER + // sort key, but a visual cue makes that obvious + // without forcing the operator to read the status pill. + const isUndeliveredInTimeMode = + isTimeMode && !o.deliverytime; + + return ( + + {showTransition && ( +
Switch to {o.pickupcustomer}
+ )} +
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} + onKeyDown={canFocus ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }); + } + } : undefined} + title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined} + > +
+
+ {displayNum} +
+
+
Order #{o.orderid}
+
+ {(() => { + // Stack the status pill and delivery time vertically on + // the right of the header so the operator sees the order + // outcome and the wall-clock time it landed at a glance. + const actual = formatTimeOnly(o.deliverytime); + const expected = formatTimeOnly(o.expecteddeliverytime); + const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()); + const showEstDrop = !isDelivered && estMeters !== null; + if (!o.orderstatus && !actual && !expected && !showEstDrop) return null; + return ( +
+ {o.orderstatus && ( + + {statusStyle.label} + + )} + {(actual || expected) && ( + + {actual || expected} + + )} + {showEstDrop && ( + + {formatMeters(estMeters)} + + )} +
+ ); + })()} + {onChangeRider && ( + + )} +
+ +
+ {o.deliverycustomer || '—'} +
+ + {o.pickupcustomer && ( +
+ {o.pickupcustomer} +
+ )} + {(o.deliverysuburb || o.deliveryaddress) && ( +
+ {o.deliverysuburb || extractArea(o.deliveryaddress)} +
+ )} + {o.ordernotes && ( +
+ {o.ordernotes} +
+ )} + +
+ + {o.actualkms || o.kms || 0} km + + + {isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)} + + {o.deliverycharge != null && ( + + ₹{parseFloat(o.deliverycharge).toFixed(0)} chg + + )} + {o.ordertype && ( + + {o.ordertype} + + )} + + T{o.trip_number || tNum} · S{o.step || idx + 1} + +
+
+
+ ); + })}
-
- {displayOrders.map((o, idx) => { - const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim(); - const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; - prevKitchenKey = kitchenKey; - const isStopActive = focusedStop && focusedStop.orderid === o.orderid; - const isGoingOn = activeOrderId && o.orderid === activeOrderId; - const lat = parseFloat(o.droplat || o.deliverylat); - const lon = parseFloat(o.droplon || o.deliverylong); - const canFocus = Number.isFinite(lat) && Number.isFinite(lon); - const statusStyle = getStatusStyle(o.orderstatus); - const profit = parseFloat(o.profit || 0); - const isLoss = profit < 0; - const estMeters = calculateEstMeters(focusedRider.id, o); - // Badge number is ALWAYS the planned step (o.step) - // — the same number regardless of sort mode. Time - // mode only reorders the cards; the badge keeps - // its dispatched identity so operators can still - // map a card on screen back to "step 3 of the - // planned route" without mental translation. - const displayNum = o.step || idx + 1; - // "Not yet delivered" indicator for time mode — we - // pushed these rows to the end via MAX_SAFE_INTEGER - // sort key, but a visual cue makes that obvious - // without forcing the operator to read the status pill. - const isUndeliveredInTimeMode = - isTimeMode && !o.deliverytime; - - return ( - - {showTransition && ( -
Switch to {o.pickupcustomer}
- )} -
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} - onKeyDown={canFocus ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }); - } - } : undefined} - title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined} - > -
-
- {displayNum} -
-
-
Order #{o.orderid}
-
- {(() => { - // Stack the status pill and delivery time vertically on - // the right of the header so the operator sees the order - // outcome and the wall-clock time it landed at a glance. - const actual = formatTimeOnly(o.deliverytime); - const expected = formatTimeOnly(o.expecteddeliverytime); - const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()); - const showEstDrop = !isDelivered && estMeters !== null; - if (!o.orderstatus && !actual && !expected && !showEstDrop) return null; - return ( -
- {o.orderstatus && ( - - {statusStyle.label} - - )} - {(actual || expected) && ( - - {actual || expected} - - )} - {showEstDrop && ( - - {formatMeters(estMeters)} - - )} -
- ); - })()} - {onChangeRider && ( - - )} -
- -
- {o.deliverycustomer || '—'} -
- - {o.pickupcustomer && ( -
- {o.pickupcustomer} -
- )} - {(o.deliverysuburb || o.deliveryaddress) && ( -
- {o.deliverysuburb || extractArea(o.deliveryaddress)} -
- )} - {o.ordernotes && ( -
- {o.ordernotes} -
- )} - -
- - {o.actualkms || o.kms || 0} km - - - {isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)} - - {o.deliverycharge != null && ( - - ₹{parseFloat(o.deliverycharge).toFixed(0)} chg - - )} - {o.ordertype && ( - - {o.ordertype} - - )} - - T{o.trip_number || tNum} · S{o.step || idx + 1} - -
-
-
- ); - })} -
- ); }); })()} @@ -5511,8 +5553,8 @@ const Dispatch = ({ if (!uid) return fallback || '—'; // The placeholder "Rider 883" pattern is what we WANT to upgrade, // so don't treat it as a usable fallback — keep looking. Only - // accept the fallback if it's a real name (no "Rider " - // pattern). + // accept the fallback if it's a real name (no "Rider " + // pattern). const fallbackIsPlaceholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim()); const liveMatch = Array.isArray(riders) ? riders.find((x) => String(x.id) === uid) @@ -5612,27 +5654,38 @@ const Dispatch = ({ Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'} - +
+ {/* Live indicator: confirms to the operator that the + panel is polling and not just a stale snapshot. */} + + + Live · {Math.round(ANALYSIS_POLL_MS / 1000)}s + + +
{/* ─── Hero strip: the three numbers an ops lead glances at @@ -5924,9 +5977,10 @@ const Dispatch = ({ {/* Stats row: kitchen (with confidence dot - when low), orders mini-bar, pace, idle. */} + when low), orders mini-bar, pace, active, + idle, free-window, and last GPS position. */}
- {r.kitchen && ( + {r.kitchen ? ( {r.kitchen} {r.kitchen_confidence != null && ( @@ -5936,6 +5990,14 @@ const Dispatch = ({ /> )} + ) : ( + + Unassigned + )} @@ -5969,6 +6031,26 @@ const Dispatch = ({ > {r.idle_minutes ?? 0} min idle + {/* Free window: time available before the next + batch starts. Often equals idle_minutes but + diverges when the next batch sits closer to + this rider's finish — operators use this to + decide who can take an extra run. */} + {r.free_window_minutes != null && ( + = 30 + ? { background: '#ecfdf5', color: '#065f46', borderColor: '#a7f3d0' } + : r.free_window_minutes > 0 + ? { background: '#fefce8', color: '#854d0e', borderColor: '#fde68a' } + : { background: '#fef2f2', color: '#991b1b', borderColor: '#fecaca' } + } + title="Free window before the next batch — capacity available for a follow-up run" + > + {parseFloat(r.free_window_minutes).toFixed(0)} min free + + )}
); @@ -6073,6 +6155,117 @@ const Dispatch = ({ )} + {/* ─── Rider last-position modal (Analysis section). + A small Leaflet map pinned to the rider's `last_position` from + /batch/efficiency. Mounted at the top level so the MapContainer + is created/destroyed exactly once per open — Leaflet maps are + expensive to remount and behave badly when their parent moves + around in the tree. */} + {riderPositionModal && ( +
{ + if (e.target === e.currentTarget) setRiderPositionModal(null); + }} + > +
+
+
+
+ +
+
+
{riderPositionModal.name}
+
+ #{riderPositionModal.userid} + {riderPositionModal.kitchen && <> · {riderPositionModal.kitchen}} + {riderPositionModal.finished_at && <> · finished {riderPositionModal.finished_at}} +
+
+
+
+ + {riderPositionModal.status || '—'} + + +
+
+ +
+ { + const map = e.target; + requestAnimationFrame(() => map.invalidateSize()); + setTimeout(() => map.invalidateSize(), 260); + }} + > + + + + {riderPositionModal.name} +
+ {riderPositionModal.lat.toFixed(6)}, {riderPositionModal.lon.toFixed(6)} +
+
+
+
+ +
+
+ + + Lat: {riderPositionModal.lat.toFixed(6)} + · + Lon: {riderPositionModal.lon.toFixed(6)} + +
+ +
+
+
+ )} + ); };