updates on the dispatch page and active section
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={o.orderid}
|
||||
className="rcard active-delivery-card"
|
||||
onClick={() => {
|
||||
if (rider) handleRiderFocus(rider);
|
||||
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
|
||||
}}
|
||||
style={{ animationDelay: `${i * 0.05}s` }}
|
||||
>
|
||||
<div className="rcard-top">
|
||||
<div className="rcard-emo" style={{ background: `${color}18`, borderColor: `${color}50`, color }}><MdLocationOn /></div>
|
||||
<div className="rcard-info">
|
||||
<div className="rcard-name" title={customer}>{customer}</div>
|
||||
<div className="rcard-zone">
|
||||
<Ico><MdTwoWheeler /></Ico>{o.rider_name || o.ridername || 'Unassigned'}{dropArea ? ` · ${dropArea}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rcard-badge" style={{ background: statusStyle.bg, color: statusStyle.fg }} title={statusStyle.label}>
|
||||
{statusStyle.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rcard-meta">
|
||||
{o.pickupcustomer && <span><Ico><MdRestaurant /></Ico>{o.pickupcustomer}</span>}
|
||||
<span><Ico><MdStraighten /></Ico>{parseFloat(o.actualkms || o.kms || 0).toFixed(1)} km</span>
|
||||
{estMeters !== null && (
|
||||
<span className="rcard-est-meters" title="Estimated distance to drop location">
|
||||
<Ico><MdMyLocation /></Ico>{formatMeters(estMeters)} to drop
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 = ({
|
||||
)}
|
||||
<div id="sidebar">
|
||||
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
|
||||
rider is focused, since the focused-rider view already shows that rider's
|
||||
stats prominently (name + Orders/Distance tiles). */}
|
||||
{!focusedRider && (
|
||||
rider is focused (the focused-rider view shows that rider's stats), and
|
||||
also hidden in the Active view when there are no in-progress deliveries,
|
||||
so the empty state isn't topped by a "0 orders / 0 riders" header. */}
|
||||
{!focusedRider && !(isAllActiveView && activeDeliveryCount === 0) && (
|
||||
<div className="sb-header">
|
||||
<div className="sb-header-top">
|
||||
<div className="sb-header-title">
|
||||
@@ -3787,12 +3875,16 @@ const Dispatch = ({
|
||||
<>
|
||||
<div className="rd-rider-name" style={{ color: focusedRider.color }}>{focusedRider.riderName}</div>
|
||||
{(() => {
|
||||
const totalKm = focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
|
||||
// Active view: the panel monitors just the in-progress
|
||||
// delivery, so the tiles reflect that one stop alone.
|
||||
const activeOnly = isAllActiveView ? getActiveOrder(focusedRider.orders) : null;
|
||||
const panelOrders = isAllActiveView ? (activeOnly ? [activeOnly] : []) : focusedRider.orders;
|
||||
const totalKm = panelOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
|
||||
return (
|
||||
<div className="rd-stats-grid">
|
||||
<div className="rd-stat rd-stat-orders">
|
||||
<div className="rd-stat-icon"><MdInventory2 /></div>
|
||||
<div className="rd-stat-value">{focusedRider.orders.length}</div>
|
||||
<div className="rd-stat-value">{panelOrders.length}</div>
|
||||
<div className="rd-stat-label">Orders</div>
|
||||
</div>
|
||||
<div className="rd-stat rd-stat-distance">
|
||||
@@ -3804,8 +3896,20 @@ const Dispatch = ({
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
// Active view: collapse the trip list down to the single
|
||||
// in-progress delivery so the panel shows the active
|
||||
// delivery alone (matches the single polyline on the map).
|
||||
const activeOnly = isAllActiveView ? getActiveOrder(focusedRider.orders) : null;
|
||||
const panelOrders = isAllActiveView ? (activeOnly ? [activeOnly] : []) : focusedRider.orders;
|
||||
if (isAllActiveView && panelOrders.length === 0) {
|
||||
return (
|
||||
<div className="trip-empty-note" style={{ padding: '14px 4px', color: '#94a3b8', fontSize: 13 }}>
|
||||
No active delivery right now.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const trips = {};
|
||||
focusedRider.orders.forEach(o => {
|
||||
panelOrders.forEach(o => {
|
||||
const t = o.trip_number || 1;
|
||||
if (!trips[t]) trips[t] = [];
|
||||
trips[t].push(o);
|
||||
@@ -4300,12 +4404,17 @@ const Dispatch = ({
|
||||
</div>
|
||||
) : (
|
||||
<div id="riders-panel">
|
||||
<div className="ph">{
|
||||
viewMode === 'zones' ? 'Zone dispatch' :
|
||||
viewMode === 'kitchens' ? 'Kitchen dispatch' :
|
||||
viewMode === 'all' ? 'Active rider dispatch' :
|
||||
'Rider dispatch'
|
||||
}</div>
|
||||
{/* Hide the panel title in the Active view when there are no
|
||||
in-progress deliveries, so the empty state isn't topped by
|
||||
an "Active rider dispatch" heading. */}
|
||||
{!(isAllActiveView && activeDeliveryCount === 0) && (
|
||||
<div className="ph">{
|
||||
viewMode === 'zones' ? 'Zone dispatch' :
|
||||
viewMode === 'kitchens' ? 'Kitchen dispatch' :
|
||||
viewMode === 'all' ? 'Active rider dispatch' :
|
||||
'Rider dispatch'
|
||||
}</div>
|
||||
)}
|
||||
<div id="rider-cards">
|
||||
{allOrders.length === 0 && !liveIsFetching ? (
|
||||
(() => {
|
||||
@@ -4422,16 +4531,38 @@ const Dispatch = ({
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : isAllActiveView && visibleRiders.length === 0 ? (
|
||||
<div className="empty-slot">
|
||||
<div className="empty-slot-icon">
|
||||
<MdTwoWheeler />
|
||||
</div>
|
||||
<div className="empty-slot-title">No active riders</div>
|
||||
<div className="empty-slot-sub">
|
||||
No riders are currently live on the road for this slot
|
||||
</div>
|
||||
</div>
|
||||
) : isAllActiveView ? (
|
||||
// Active view: list the in-progress deliveries themselves,
|
||||
// grouped by rider name then trip/step — NOT rider cards.
|
||||
(() => {
|
||||
const activeDeliveries = allViewOrders
|
||||
.filter(isActiveDelivery)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const rn = String(a.rider_name || a.ridername || '').localeCompare(
|
||||
String(b.rider_name || b.ridername || '')
|
||||
);
|
||||
if (rn !== 0) return rn;
|
||||
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);
|
||||
});
|
||||
if (activeDeliveries.length === 0) {
|
||||
return (
|
||||
<div className="empty-slot">
|
||||
<div className="empty-slot-icon">
|
||||
<MdInventory2 />
|
||||
</div>
|
||||
<div className="empty-slot-title">No active deliveries</div>
|
||||
<div className="empty-slot-sub">
|
||||
No deliveries are currently in progress for this slot
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return activeDeliveries.map(renderActiveDeliveryCard);
|
||||
})()
|
||||
) : (
|
||||
visibleRiders.map(renderRiderCard)
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,28 @@ export const getStatusStyle = (status) =>
|
||||
export const FINAL_STATUSES = new Set(['delivered']);
|
||||
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Per-step palette — wider and more deliberately spaced than the rider
|
||||
// palette so a 10-stop day reads as 10 distinct colors on the compare
|
||||
// map's polylines + pins.
|
||||
|
||||
Reference in New Issue
Block a user