diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 841dcd5..04c84f0 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -2113,6 +2113,194 @@ border: 1px solid var(--border); } +/* ── Active-delivery card (Active view sidebar) ───────────────────────────── + Clean monitoring card: rider avatar + customer/rider header + status pill, a + truncated drop-address line, and a footer with the pickup kitchen on the left + and distance + ETA-to-drop on the right. `--ad-accent` = owning rider color. */ +.dispatch-container .adcard-list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 2px 12px; +} + +.dispatch-container .adcard { + position: relative; + background: #fff; + border: 1px solid var(--border); + border-radius: 14px; + padding: 13px 14px 13px 16px; + cursor: pointer; + overflow: hidden; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.dispatch-container .adcard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 3px; + height: 100%; + background: var(--ad-accent, var(--accent)); + opacity: 0.9; + transition: width 0.18s ease; +} + +.dispatch-container .adcard:hover { + transform: translateY(-2px); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); + border-color: var(--ad-accent, var(--accent)); +} + +.dispatch-container .adcard:hover::before { + width: 5px; +} + +.dispatch-container .adcard.is-active { + border-color: var(--ad-accent, var(--accent)); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); +} + +.dispatch-container .adcard.is-active::before { + width: 5px; +} + +.dispatch-container .adcard-top { + display: flex; + align-items: center; + gap: 11px; +} + +.dispatch-container .adcard-avatar { + width: 38px; + height: 38px; + border-radius: 11px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 800; + color: #fff; + letter-spacing: 0.02em; +} + +.dispatch-container .adcard-titles { + flex: 1; + min-width: 0; +} + +.dispatch-container .adcard-customer { + font-size: 14.5px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dispatch-container .adcard-rider { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dispatch-container .adcard-status { + flex-shrink: 0; + align-self: flex-start; + font-size: 9.5px; + font-weight: 800; + padding: 4px 9px; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.dispatch-container .adcard-addr { + display: flex; + align-items: center; + gap: 7px; + margin-top: 11px; + font-size: 12px; + color: var(--text-muted); + min-width: 0; +} + +.dispatch-container .adcard-addr .adcard-ic { + color: #94a3b8; +} + +.dispatch-container .adcard-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 11px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.dispatch-container .adcard-pickup { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--text); +} + +.dispatch-container .adcard-pickup .adcard-ic { + color: var(--kitchen); +} + +.dispatch-container .adcard-metrics { + display: inline-flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dispatch-container .adcard-m { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dispatch-container .adcard-m-km { + color: var(--text-muted); +} + +.dispatch-container .adcard-m-eta { + color: var(--ad-accent, var(--accent)); +} + +.dispatch-container .adcard-ic { + flex-shrink: 0; + display: inline-flex; + align-items: center; + font-size: 14px; +} + +.dispatch-container .adcard-tx { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + + .dispatch-container .zone-order-change-rider { flex-shrink: 0; width: 26px; diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 3499f9f..21f0b14 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -56,6 +56,8 @@ import { getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, + isActiveDelivery, + getActiveOrder, STEP_PALETTE, stepColor } from './dispatchShared'; @@ -1706,6 +1708,15 @@ const Dispatch = ({ }; }, [focusedRider, focusedKitchen, isAllActiveView, allViewOrders, visibleRiders, stats]); + // 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] + ); + // List of deliveryids we want GPS logs for. Drives two pipelines: // • renderRoutes() — actual-route polylines on the main map for // every visible rider/trip @@ -2499,6 +2510,58 @@ const Dispatch = ({ ); }; + // Delivery-centric card for the "Active" view sidebar. Instead of one card + // per rider, the Active view lists the in-progress deliveries themselves so + // operators monitor the work, not the people. Clicking focuses the owning + // rider and centers the map on the drop (which, per the Active-view rules, + // collapses to that rider's single active leg + drop pin). + 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 || ''; + + return ( +
{ + if (rider) handleRiderFocus(rider); + if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon }); + }} + style={{ animationDelay: `${i * 0.05}s` }} + > +
+
+
+
{customer}
+
+ {o.rider_name || o.ridername || 'Unassigned'}{dropArea ? ` · ${dropArea}` : ''} +
+
+
+ {statusStyle.label} +
+
+
+ {o.pickupcustomer && {o.pickupcustomer}} + {parseFloat(o.actualkms || o.kms || 0).toFixed(1)} km + {estMeters !== null && ( + + {formatMeters(estMeters)} to drop + + )} +
+
+ ); + }; + // Returns true when the order's centered popup should stay open even after // the cursor leaves the marker: either explicitly pinned via click, or the // matching compare-step is focused (so clicking a step in the right panel @@ -2666,6 +2729,21 @@ const Dispatch = ({ if (focusedRider) ordersToRender = focusedRider.orders; ordersToRender = ordersToRender.filter(hasValidDrop); + // Active view drop pins: + // • Focused rider → only that rider's single in-progress delivery, so the + // pin lines up with the single active polyline drawn in renderRoutes(). + // • Overview (no focus) → every active delivery, matching the active- + // delivery list in the sidebar. + if (isAllActiveView) { + if (focusedRider) { + const active = getActiveOrder(focusedRider.orders); + const id = active ? String(active.orderid) : null; + ordersToRender = id ? ordersToRender.filter((o) => String(o.orderid) === id) : []; + } else { + ordersToRender = ordersToRender.filter(isActiveDelivery); + } + } + // Pre-build the deliveryid → sequenceStep lookup once per render so each // marker can resolve its step palette color without an O(N) scan. const compareDeliveryToStep = @@ -2787,7 +2865,12 @@ const Dispatch = ({ if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return; if (zoneRiderIds && !zoneRiderIds.has(String(r.id))) return; - const rOrders = r.orders; + // In the Active view, collapse the rider down to just their in-progress + // delivery so the map draws only that one leg's polyline instead of the + // whole day's route. Every other view keeps the full order list. + const activeOrder = isAllActiveView ? getActiveOrder(r.orders) : null; + const rOrders = isAllActiveView ? (activeOrder ? [activeOrder] : []) : r.orders; + if (isAllActiveView && rOrders.length === 0) return; const trips = {}; rOrders.forEach(o => { const t = o.trip_number || 1; @@ -2882,8 +2965,12 @@ const Dispatch = ({ // false → OSRM permanently failed (draw aerial fallback so user sees something) // null → request in-flight (DON'T draw anything yet — avoids the aerial flash) // undefined → not yet requested (same as in-flight, wait) - const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2; - const failed = roadPoints === false; + // The cached OSRM route is keyed per *trip*, so it covers every stop + // and can't represent a single active leg. In the Active view we skip + // it and build the planned line straight from the (already single-order) + // `sorted` list, so the fallback stays scoped to the active delivery. + const hasRoad = !isAllActiveView && Array.isArray(roadPoints) && roadPoints.length >= 2; + const failed = isAllActiveView ? true : roadPoints === false; if (!hasRoad && !failed) return; // still loading — don't show aerial flash const finalPoints = hasRoad ? roadPoints : buildTripPoints(sorted); @@ -3738,9 +3825,10 @@ const Dispatch = ({ )}