diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 04c84f0..cbd69c1 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -2203,13 +2203,14 @@ } .dispatch-container .adcard-rider { + display: flex; + align-items: center; + gap: 5px; font-size: 12px; font-weight: 600; color: var(--text-muted); - margin-top: 1px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + margin-top: 2px; + min-width: 0; } .dispatch-container .adcard-status { @@ -2300,6 +2301,25 @@ min-width: 0; } +/* Drop address: allow up to two lines instead of one truncated line so long + addresses stay readable; the location icon hugs the first line. */ +.dispatch-container .adcard-addr { + align-items: flex-start; +} + +.dispatch-container .adcard-addr .adcard-ic { + margin-top: 1px; +} + +.dispatch-container .adcard-addr-tx { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; +} + .dispatch-container .zone-order-change-rider { flex-shrink: 0; diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 21f0b14..25704f7 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -1298,7 +1298,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. @@ -1554,58 +1561,52 @@ const Dispatch = ({ ? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null) : internalFocusedRider; - // "All Active Routes" view scoping. In this mode we render only riders whose - // live GPS log is `active` right now — their cards, routes, drop markers and - // bike markers — so the operator sees the on-road fleet at a glance. Every - // other view (By Location / By Zone / By Rider) is unchanged. + // "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'; - // Active riders the GPS feed reports as on-road RIGHT NOW that have no orders - // in the current data (i.e. nothing to deliver). We still want them on screen - // in "All Active Routes" — just their live bike marker, no route — so we - // synthesize order-less rider objects shaped like real ones. `gpsOnly` flags - // them so the card / route code treats them as "live position only". - const gpsOnlyActiveRiders = useMemo(() => { - if (!isAllActiveView) return []; - const haveOrders = new Set(riders.map((r) => String(r.id))); - return liveRiderLocations - .filter((r) => r.status === 'active' && !haveOrders.has(String(r.id))) - .map((r) => ({ - id: r.id, - riderName: r.username || `Rider #${r.id}`, - orders: [], - color: getStableRiderColor(r.id), - gpsOnly: true, - // Live position — lets MapController center on the rider when their - // GPS-only card is clicked (they have no drops to fit to). - lat: r.lat, - lon: r.lon - })); - }, [isAllActiveView, liveRiderLocations, riders]); - // The riders we render in "All Active Routes": every rider whose live GPS is - // active — those with orders (real route shown) PLUS those without (GPS only). - // Every other view (By Location / By Zone / By Rider) is unchanged. - const visibleRiders = useMemo( - () => - isAllActiveView - ? [...riders.filter((r) => activeRiderIdSet.has(String(r.id))), ...gpsOnlyActiveRiders] - : riders, - [isAllActiveView, riders, activeRiderIdSet, gpsOnlyActiveRiders] - ); - // Orders that belong to the riders we're actually showing — drives the drop - // markers and the map auto-fit bounds in the active view. + // 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] ); - // Live GPS coordinates of every active rider in the "All Active Routes" view. - // Fed to MapController's auto-fit so order-less (GPS-only) active riders are - // framed even when there are no drop markers to anchor the bounds. + // 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').map((r) => [r.lat, r.lon]) + ? liveRiderLocations + .filter((r) => r.status === 'active' && activeOrderRiderIdSet.has(String(r.id))) + .map((r) => [r.lat, r.lon]) : [], - [isAllActiveView, liveRiderLocations] + [isAllActiveView, liveRiderLocations, activeOrderRiderIdSet] ); // Per-rider canvas renderer for the actual (right) map in Compare mode. @@ -1687,15 +1688,17 @@ const Dispatch = ({ }; } // "All Active Routes": the header must reflect exactly what the list/map - // shows — the active fleet (riders with orders + GPS-only riders) and their - // orders — NOT the whole day's totals. Otherwise the top "Riders" tile (full - // fleet) disagrees with the rider list below (active-only). + // 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: allViewOrders.length, + orders: activeOrders.length, riders: visibleRiders.length, - km: allViewOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0), - profit: allViewOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0), + 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' }; } @@ -2183,8 +2186,30 @@ const Dispatch = ({ const pts = buildTripPoints(sorted); if (pts.length >= 2) fetchRoute(r.id, tNum, pts); }); + + // Active view: fetch a road-following route scoped to the rider's single + // in-progress leg — current live GPS position → drop (pickup as fallback + // when no live fix). Cached under a per-order key so renderRoutes() can + // draw the road polyline for that leg instead of a straight line, and so + // it never collides with the per-trip route fetched above. + if (isAllActiveView) { + const activeOrder = getActiveOrder(r.orders); + if (activeOrder) { + const lp = liveRiderLocations.find((l) => String(l.id) === String(r.id)); + const start = (lp && Number.isFinite(lp.lat) && Number.isFinite(lp.lon)) + ? [lp.lat, lp.lon] + : (hasValidPickup(activeOrder) + ? [toNum(pickupLat(activeOrder)), toNum(pickupLon(activeOrder))] + : null); + const dLat = toNum(activeOrder.droplat || activeOrder.deliverylat); + const dLon = toNum(activeOrder.droplon || activeOrder.deliverylong); + if (start && Number.isFinite(dLat) && Number.isFinite(dLon)) { + fetchRoute(r.id, `active-${activeOrder.orderid}`, [start, [dLat, dLon]]); + } + } + } }); - }, [riders, activeRiders, focusedRider, fetchRoute]); + }, [riders, activeRiders, focusedRider, isAllActiveView, liveRiderLocations, fetchRoute]); // Auto-advance the selected slot when the wall-clock moves into a new slot's // window — BUT only if the user is still sitting on the slot that's just been @@ -2526,37 +2551,75 @@ const Dispatch = ({ 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'; + // This card is "active" (popped on the map) when its drop is the focused stop. + const isActive = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid); + // Up-to-two-letter rider initials for the avatar. + const initials = + riderName + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]) + .join('') + .toUpperCase() || '•'; return (