diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index 5c381cd..9f58c31 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -923,6 +923,7 @@ const Deliveries = () => { Kms Amount Notes + Steps Qty COD {tabstatus !== 'Cancelled' && tabstatus !== 'Delivered' && ( @@ -944,7 +945,7 @@ const Deliveries = () => { {rows.length == 0 && !loading1 && ( <> - + {/* */} @@ -1125,10 +1126,27 @@ const Deliveries = () => { - {/* {qty} */} + {/* notes */} {row.notes} + {/* step */} + + {row.step ? ( + + ) : ( + + — + + )} + + {/* qty */} {row.Quantity ? ( diff --git a/src/pages/nearle/dispatch/CompareDataPanel.js b/src/pages/nearle/dispatch/CompareDataPanel.js index 8b0f019..1e53258 100644 --- a/src/pages/nearle/dispatch/CompareDataPanel.js +++ b/src/pages/nearle/dispatch/CompareDataPanel.js @@ -13,7 +13,11 @@ import { MdFormatListBulleted, MdTimer, MdWarning, - MdClose + MdClose, + MdSpeed, + MdStar, + MdFlag, + MdHourglassBottom } from 'react-icons/md'; import { stepColor, @@ -513,48 +517,126 @@ function CompareDataPanel({ )} - {/* Timing KPIs */} + {/* Timing — clock-style timeline. First/last delivery render as + digital clock faces flanking a duration centerpiece. Tiny + "Started" / "Finished" captions give the row a narrative. + Below: avg-per-stop with a dotted stops-row visualization, + and avg speed with a 0-60 gauge bar. */} {(firstDelivery || lastDelivery) && ( -
+
- Timing KPIs + Timing + {activeMin > 0 && ( + + + Day window + + )}
-
-
-
First delivery
-
- {firstDelivery ? firstDelivery.format('hh:mm A') : '—'} +
+
+
+ First delivery +
+
+ + {firstDelivery ? firstDelivery.format('hh:mm') : '—'} + + + {firstDelivery ? firstDelivery.format('A') : ''} + +
+
Started
+
+ -
-
Last delivery
-
- {lastDelivery ? lastDelivery.format('hh:mm A') : '—'} +
+
+ Last delivery
+
+ + {lastDelivery ? lastDelivery.format('hh:mm') : '—'} + + + {lastDelivery ? lastDelivery.format('A') : ''} + +
+
Finished
-
-
Active time
-
- {activeMin > 0 - ? activeMin >= 60 - ? `${Math.floor(activeMin / 60)}h ${activeMin % 60}m` - : `${activeMin}m` - : '—'} -
-
-
-
Avg / stop
-
- {avgPerStop > 0 ? `${avgPerStop}m` : '—'} +
+
+
+
+
+ +
+
+
+ {avgPerStop > 0 ? `${avgPerStop}` : '—'} + {avgPerStop > 0 && ( + min + )} +
+
Avg / stop
+
+ {compareDeltas.length > 0 && ( + + )}
{avgSpeed != null && ( -
-
Avg speed
-
- {avgSpeed} - km/h +
+
+
+ +
+
+
+ {avgSpeed} + km/h +
+
Avg speed
+
+
+ )} @@ -562,11 +644,14 @@ function CompareDataPanel({
)} - {/* Highlights — best/worst step quick-pick. */} + {/* Highlights — best/worst step quick-pick. Full-width cards stacked + vertically: a colored rail on the left side encodes good/bad, + the customer name is the headline, the step number sits as a + right-aligned chip, and the metric line uses bold pills. */} {(bestStep || worstStep) && (
- + Highlights
@@ -577,23 +662,35 @@ function CompareDataPanel({ role="button" title="Focus this step" > - - {bestStep.sequenceStep} - -
-
- Fastest stop +
@@ -605,26 +702,39 @@ function CompareDataPanel({ role="button" title="Focus this step" > - - {worstStep.sequenceStep} - -
-
- Biggest deviation +
diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index f813660..bef0ca7 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -3486,14 +3486,31 @@ height: 4px; } -/* Solid step-color swatch used by both "Planned (left)" and "Actual GPS - (right)" entries — they now share the same per-step palette so the same - color appears on both maps for the same delivery. */ +/* Solid step-color swatch used by both Planned and Actual legend entries + on the unified compare map — they share the same per-step palette and + are distinguished by stroke style instead. */ .dispatch-container .compare-legend-swatch.is-step-color { height: 4px; border-radius: 2px; } +/* Dashed variant — mirrors the planned polyline's dashed stroke in + Combined view. Masks the gradient swatch with a tiled transparent + gap so the eye reads "dashed line" without losing the step-color + gradient underneath. */ +.dispatch-container .compare-legend-swatch.is-step-color.is-dashed { + -webkit-mask-image: repeating-linear-gradient( + 90deg, + #000 0 5px, + transparent 5px 9px + ); + mask-image: repeating-linear-gradient( + 90deg, + #000 0 5px, + transparent 5px 9px + ); +} + /* Small status note in the legend strip — replaces the now-removed "Transit (no GPS)" item with a one-liner telling the operator how the actual-GPS polyline gets built (Kalman smooth + OSRM road-snap). */ @@ -4451,6 +4468,18 @@ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18); overflow: hidden; min-width: 460px; + animation: dispatch-popup-in 0.18s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Soften the popup tip the same way — without this, the tip pops in at + full opacity while the wrapper fades, which reads as a jarring snap. */ +.dispatch-container .dispatch-popup .leaflet-popup-tip { + animation: dispatch-popup-in 0.18s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes dispatch-popup-in { + from { opacity: 0; transform: translateY(4px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } } .dispatch-container .dispatch-popup .leaflet-popup-content { @@ -5177,9 +5206,9 @@ .dispatch-container .ri-rider-item { display: flex; align-items: center; - gap: 10px; + gap: 12px; width: 100%; - padding: 10px 12px; + padding: 12px 14px; border: 1px solid rgba(123, 31, 162, 0.12); border-radius: 10px; background: #fff; @@ -5211,8 +5240,8 @@ } .dispatch-container .ri-rider-dot { - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(15, 23, 42, 0.08); @@ -5222,27 +5251,29 @@ flex: 1; display: flex; flex-direction: column; - gap: 2px; + gap: 3px; min-width: 0; } .dispatch-container .ri-rider-name { - font-size: 13px; + font-size: 15px; font-weight: 700; color: #1e293b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + letter-spacing: -0.01em; } .dispatch-container .ri-rider-meta { - font-size: 11px; - font-weight: 500; + font-size: 13px; + font-weight: 600; color: #64748b; } .dispatch-container .ri-rider-arrow { color: #7b1fa2; + font-size: 18px; font-weight: 800; opacity: 0.4; transition: opacity 0.15s ease, transform 0.15s ease; @@ -6095,16 +6126,17 @@ } /* ============================================================ - Compare screen — new layout (takes over the body when Compare - is open). 50% left column is a vertical stack: actual map on - top, planned map on bottom. 50% right column is a scrollable - data panel with deviations, per-step correctness, delivery - times and profit. + Compare screen — unified layout. Compare mode takes over the + body: one big map fills the left column (with a Planned / + Actual / Combined view switcher pinned to its top-left), a + compact timeline + legend strip sits above the map, and the + scrollable data panel (deviations, per-step correctness, + delivery times, profit) anchors the right column. ============================================================ */ .dispatch-container #body.compare-mode { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); gap: 12px; padding: 12px; background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%); @@ -6115,22 +6147,31 @@ display: none !important; } -/* Explicit grid placement — the actual GPS map (#compare-map-wrap) takes - the TOP-LEFT cell, the planned route map (#map-wrap) takes the - BOTTOM-LEFT cell, and the data panel spans the entire right column. - We use grid-row/grid-column rather than grid-template-areas so the - placement can't be quietly broken by a later override of the parent's - areas list. */ +/* Header strip — sits above the unified map (row 1, col 1) and + carries the rider title, the step timeline + load progress, and + the layer legend. The Sync toggle was removed when the second + map went away; the layer switcher is overlaid on the map itself. */ .dispatch-container #body.compare-mode #compare-map-wrap { grid-column: 1; grid-row: 1; flex: none; min-width: 0; - min-height: 0; margin: 0; + background: #ffffff; border-radius: 14px; box-shadow: 0 6px 24px rgba(15, 23, 42, 0.08), 0 0 0 1px rgba(15, 23, 42, 0.06); + overflow: hidden; + animation: compare-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* The header v2 block sat inside a flex column inside the old + compare-map-wrap. With the wrapper now sized to content, the + inner block doesn't need its own border-bottom anymore — the + wrapper's rounded card provides the visual containment. */ +.dispatch-container #body.compare-mode #compare-map-wrap .compare-header-v2 { + border-bottom: 0; + background: transparent; } .dispatch-container #body.compare-mode #map-wrap, @@ -6148,8 +6189,64 @@ overflow: hidden; } -.dispatch-container #body.compare-mode .compare-planned-label { +/* Layer switcher — pinned to the top-left corner of the unified + compare map. Segmented control: Actual | Planned | Combined. + Active button uses the same indigo→blue gradient as the Compare + pill in #ov-br so the operator can visually trace "Compare on" + to "this is the active layer". */ +.dispatch-container .compare-view-switcher { + position: absolute; + top: 12px; + left: 12px; + z-index: 600; + display: inline-flex; + align-items: stretch; + gap: 2px; + padding: 4px; background: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(8px); + border-radius: 10px; + box-shadow: 0 6px 20px rgba(15, 23, 42, 0.12), + 0 0 0 1px rgba(15, 23, 42, 0.08); + animation: compare-label-in 0.22s ease-out; +} + +.dispatch-container .compare-view-switcher button { + appearance: none; + border: 0; + background: transparent; + padding: 6px 14px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #475569; + border-radius: 7px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, + box-shadow 0.15s ease, transform 0.15s ease; + white-space: nowrap; +} + +.dispatch-container .compare-view-switcher button:hover { + background: rgba(99, 102, 241, 0.08); + color: #4338ca; +} + +.dispatch-container .compare-view-switcher button:focus-visible { + outline: 2px solid rgba(99, 102, 241, 0.5); + outline-offset: 1px; +} + +.dispatch-container .compare-view-switcher button.is-active { + background: linear-gradient(135deg, #6366f1, #3b82f6); + color: #ffffff; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35); +} + +.dispatch-container .compare-view-switcher button.is-active:hover { + color: #ffffff; + transform: translateY(-0.5px); } /* Data panel ------------------------------------------------- */ @@ -6427,14 +6524,14 @@ } .dispatch-container .cdp-dev-num { - width: 26px; - height: 26px; + width: 30px; + height: 30px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; - font-size: 12px; + font-size: 14px; font-weight: 800; flex-shrink: 0; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6), @@ -6446,11 +6543,11 @@ min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: 5px; } .dispatch-container .cdp-dev-title { - font-size: 12px; + font-size: 14px; font-weight: 700; color: #0f172a; overflow: hidden; @@ -6461,14 +6558,14 @@ .dispatch-container .cdp-dev-meta { display: flex; flex-wrap: wrap; - gap: 4px; + gap: 6px; } .dispatch-container .cdp-dev-chip { display: inline-flex; align-items: center; - padding: 2px 7px; - font-size: 10px; + padding: 3px 9px; + font-size: 12px; font-weight: 700; border-radius: 999px; background: rgba(239, 68, 68, 0.1); @@ -6521,14 +6618,14 @@ } .dispatch-container .cdp-step-num { - width: 30px; - height: 30px; + width: 34px; + height: 34px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; - font-size: 13px; + font-size: 15px; font-weight: 800; flex-shrink: 0; position: relative; @@ -6541,14 +6638,14 @@ position: absolute; bottom: -3px; right: -3px; - width: 14px; - height: 14px; + width: 16px; + height: 16px; border-radius: 50%; background: #fff; display: inline-flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 13px; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2); } .dispatch-container .cdp-step-check { color: #16a34a; } @@ -6570,7 +6667,7 @@ } .dispatch-container .cdp-step-title { - font-size: 13px; + font-size: 15px; font-weight: 700; color: #0f172a; overflow: hidden; @@ -6583,8 +6680,8 @@ .dispatch-container .cdp-step-status { display: inline-flex; align-items: center; - padding: 2px 7px; - font-size: 9px; + padding: 3px 9px; + font-size: 11px; font-weight: 800; border-radius: 999px; letter-spacing: 0.05em; @@ -6593,7 +6690,7 @@ } .dispatch-container .cdp-step-sub { - font-size: 11px; + font-size: 13px; font-weight: 600; color: #94a3b8; overflow: hidden; @@ -6604,24 +6701,24 @@ .dispatch-container .cdp-step-deltas { display: flex; flex-wrap: wrap; - gap: 6px 10px; - margin-top: 2px; + gap: 6px 12px; + margin-top: 3px; } .dispatch-container .cdp-step-delta { display: inline-flex; align-items: center; - gap: 3px; - font-size: 11px; + gap: 4px; + font-size: 13px; font-weight: 700; color: #475569; } .dispatch-container .cdp-step-delta svg { - font-size: 13px; + font-size: 15px; color: #94a3b8; } .dispatch-container .cdp-step-delta small { - font-size: 10px; + font-size: 12px; font-weight: 700; color: #94a3b8; } @@ -6631,12 +6728,14 @@ .dispatch-container .cdp-step-delta.is-under { color: #16a34a; } .dispatch-container .cdp-step-delta.is-under svg { color: #16a34a; } -/* Responsive — narrow screens collapse to a single column with the - data panel below the two maps so the maps stay usable on tablets. */ +/* Responsive — narrow screens collapse to a single column. Header strip + stacks on top, then the unified map, then the data panel scrolls + below. The map gets a generous min-height so the layer-switcher + overlay and timeline stay usable on tablets. */ @media (max-width: 1100px) { .dispatch-container #body.compare-mode { grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr) auto; + grid-template-rows: auto minmax(360px, 1fr) auto; } .dispatch-container #body.compare-mode #compare-map-wrap { grid-column: 1; @@ -6742,124 +6841,531 @@ color: #64748b; } -/* KPI row ---------------------------------------------------- */ -.dispatch-container .cdp-kpi-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); +/* Timing — clock-style timeline ----------------------------- */ +/* Premium "workday window" composition: two digital clock faces flank + a gradient track with the active-time centerpiece. Soft radial + wash on the container, "Started"/"Finished" captions tell the + narrative. Below: stats with mini-visualizations. */ +.dispatch-container .cdp-timing-section .cdp-section-head { + display: flex; + align-items: center; gap: 8px; } -.dispatch-container .cdp-kpi { - background: #fff; - border: 1px solid rgba(15, 23, 42, 0.07); - border-radius: 9px; - padding: 8px 10px; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.dispatch-container .cdp-kpi-label { +.dispatch-container .cdp-timing-active-tag { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 2px 8px 2px 6px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.1); + color: #16a34a; font-size: 9px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; - color: #94a3b8; } - -.dispatch-container .cdp-kpi-value { - font-size: 15px; - font-weight: 800; - color: #0f172a; - letter-spacing: -0.01em; -} - -.dispatch-container .cdp-kpi-unit { - font-size: 10px; - font-weight: 700; - color: #94a3b8; - margin-left: 2px; -} - -/* Highlights (best / worst) --------------------------------- */ -.dispatch-container .cdp-highlights { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.dispatch-container .cdp-highlight { - display: flex; - gap: 10px; - padding: 10px; - border-radius: 10px; - background: #fff; - border: 1px solid rgba(15, 23, 42, 0.07); - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; -} -.dispatch-container .cdp-highlight:hover { - transform: translateY(-1px); - box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); -} -.dispatch-container .cdp-highlight.is-best { - background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%); - border-color: rgba(16, 185, 129, 0.3); -} -.dispatch-container .cdp-highlight.is-worst { - background: linear-gradient(180deg, #fef2f2 0%, #fff 100%); - border-color: rgba(239, 68, 68, 0.3); -} - -.dispatch-container .cdp-highlight-num { - width: 28px; - height: 28px; +.dispatch-container .cdp-timing-active-pulse { + width: 6px; + height: 6px; border-radius: 50%; + background: #10b981; + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); + animation: cdp-timing-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} +@keyframes cdp-timing-pulse { + 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); } + 70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); } + 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } +} + +.dispatch-container .cdp-timing-clock { + position: relative; + display: grid; + grid-template-columns: 1fr minmax(70px, 1.3fr) 1fr; + align-items: center; + gap: 8px; + padding: 18px 14px 16px; + background: + radial-gradient(120% 80% at 50% 0%, rgba(99, 102, 241, 0.07) 0%, transparent 60%), + linear-gradient(180deg, #fbfcff 0%, #fff 100%); + border-radius: 14px; + border: 1px solid rgba(15, 23, 42, 0.07); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); +} + +.dispatch-container .cdp-clock-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; +} + +.dispatch-container .cdp-clock-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + font-weight: 800; + color: #64748b; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.dispatch-container .cdp-clock-label svg { + font-size: 13px; +} +.dispatch-container .cdp-clock-card.is-start .cdp-clock-label svg { color: #10b981; } +.dispatch-container .cdp-clock-card.is-end .cdp-clock-label svg { color: #f59e0b; } + +.dispatch-container .cdp-clock-face { + position: relative; + display: inline-flex; + align-items: baseline; + gap: 5px; + padding: 10px 14px; + border-radius: 12px; + background: linear-gradient(180deg, #1e293b 0%, #0b1220 100%); + box-shadow: + 0 2px 4px rgba(15, 23, 42, 0.18), + 0 8px 18px rgba(15, 23, 42, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.07), + inset 0 -1px 0 rgba(0, 0, 0, 0.4); +} +.dispatch-container .cdp-clock-face::after { + content: ''; + position: absolute; + inset: 1px; + border-radius: 11px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 50%); + pointer-events: none; +} +.dispatch-container .cdp-clock-card.is-start .cdp-clock-face { + border-top: 1px solid rgba(16, 185, 129, 0.4); +} +.dispatch-container .cdp-clock-card.is-end .cdp-clock-face { + border-top: 1px solid rgba(245, 158, 11, 0.4); +} + +.dispatch-container .cdp-clock-time { + font-family: 'SF Mono', 'Roboto Mono', 'Menlo', 'Consolas', monospace; + font-size: 24px; + font-weight: 700; + color: #f1f5f9; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + line-height: 1; + text-shadow: 0 0 12px rgba(167, 195, 255, 0.18); +} + +.dispatch-container .cdp-clock-period { + font-size: 10px; + font-weight: 800; + color: #cbd5e1; + letter-spacing: 0.1em; + padding: 1px 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); +} + +.dispatch-container .cdp-clock-caption { + font-size: 9px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #94a3b8; +} +.dispatch-container .cdp-clock-card.is-start .cdp-clock-caption { color: #16a34a; } +.dispatch-container .cdp-clock-card.is-end .cdp-clock-caption { color: #d97706; } + +/* Timeline track — line stretches the full column with the duration + badge anchored on top. */ +.dispatch-container .cdp-clock-track { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 60px; + min-width: 70px; +} + +.dispatch-container .cdp-clock-track-line { + position: absolute; + left: 4px; + right: 4px; + top: 50%; + height: 4px; + transform: translateY(-50%); + background: linear-gradient(90deg, #10b981 0%, #6366f1 50%, #f59e0b 100%); + border-radius: 999px; + box-shadow: 0 1px 2px rgba(99, 102, 241, 0.18); +} + +.dispatch-container .cdp-clock-track-dot { + position: absolute; + top: 50%; + width: 11px; + height: 11px; + border-radius: 50%; + transform: translateY(-50%); + z-index: 1; +} +.dispatch-container .cdp-clock-track-dot.is-start { + left: 0; + background: #10b981; + box-shadow: + 0 0 0 2px #fff, + 0 0 0 5px rgba(16, 185, 129, 0.25); +} +.dispatch-container .cdp-clock-track-dot.is-end { + right: 0; + background: #f59e0b; + box-shadow: + 0 0 0 2px #fff, + 0 0 0 5px rgba(245, 158, 11, 0.25); +} + +.dispatch-container .cdp-clock-duration { + position: relative; + z-index: 2; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 6px 10px 5px; + border-radius: 12px; + background: #fff; + border: 1px solid rgba(99, 102, 241, 0.2); + box-shadow: + 0 4px 12px rgba(15, 23, 42, 0.08), + 0 1px 3px rgba(99, 102, 241, 0.12); + white-space: nowrap; + min-width: 56px; +} + +.dispatch-container .cdp-clock-duration-icon { display: inline-flex; align-items: center; justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%); color: #fff; font-size: 12px; - font-weight: 800; - flex-shrink: 0; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7), - 0 1px 3px rgba(15, 23, 42, 0.15); + margin-bottom: 3px; + box-shadow: 0 2px 4px rgba(99, 102, 241, 0.35); } -.dispatch-container .cdp-highlight-body { +.dispatch-container .cdp-clock-duration-val { + font-size: 14px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; + line-height: 1.1; +} +.dispatch-container .cdp-clock-duration-sub { + font-size: 8px; + font-weight: 800; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-top: 1px; +} + +/* Supporting timing stats with mini-visualizations. */ +.dispatch-container .cdp-timing-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.dispatch-container .cdp-timing-stat { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + background: #fff; + border-radius: 12px; + border: 1px solid rgba(15, 23, 42, 0.07); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} +.dispatch-container .cdp-timing-stat:hover { + border-color: rgba(99, 102, 241, 0.3); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1); + transform: translateY(-1px); +} + +.dispatch-container .cdp-timing-stat-head { + display: flex; + align-items: center; + gap: 10px; +} + +.dispatch-container .cdp-timing-stat-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(67, 56, 202, 0.1) 100%); + color: #4338ca; + font-size: 20px; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.12); +} + +.dispatch-container .cdp-timing-stat-body { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.dispatch-container .cdp-timing-stat-value { + font-size: 18px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.dispatch-container .cdp-timing-stat-unit { + font-size: 11px; + font-weight: 700; + color: #94a3b8; + margin-left: 3px; +} + +.dispatch-container .cdp-timing-stat-label { + font-size: 10px; + font-weight: 800; + color: #94a3b8; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* Mini-visualizations under each stat. */ +.dispatch-container .cdp-timing-stat-viz { + display: flex; + align-items: center; + gap: 6px; + padding-top: 8px; + border-top: 1px dashed rgba(15, 23, 42, 0.08); +} + +.dispatch-container .cdp-timing-stat-viz-label { + font-size: 10px; + font-weight: 700; + color: #94a3b8; + margin-left: auto; + white-space: nowrap; +} + +/* Stops-row — small dots, one per stop (capped at 12). */ +.dispatch-container .cdp-stops-dots { + flex-wrap: wrap; +} +.dispatch-container .cdp-stop-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%); + box-shadow: 0 1px 2px rgba(99, 102, 241, 0.3); + flex-shrink: 0; +} + +/* Speed gauge — 0-60 scale bar with filled segment. */ +.dispatch-container .cdp-speed-gauge { + flex-direction: column; + align-items: stretch; + gap: 4px; +} + +.dispatch-container .cdp-speed-gauge-track { + position: relative; + height: 6px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.06); + overflow: hidden; +} + +.dispatch-container .cdp-speed-gauge-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #10b981 0%, #6366f1 60%, #f59e0b 100%); + box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3); + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.dispatch-container .cdp-speed-gauge-scale { + display: flex; + justify-content: space-between; + font-size: 9px; + font-weight: 700; + color: #94a3b8; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; +} + +/* Highlights (best / worst) --------------------------------- */ +/* Full-width vertically-stacked cards. A 4px colored rail on the left + encodes good/bad. Inside: a label-icon header + a right-aligned step + chip, then the customer name as the headline, then metric pills. */ +.dispatch-container .cdp-highlights { + display: flex; + flex-direction: column; + gap: 10px; +} + +.dispatch-container .cdp-highlight { + position: relative; + display: flex; + align-items: stretch; + padding: 0; + border-radius: 12px; + background: #fff; + border: 1px solid rgba(15, 23, 42, 0.07); + cursor: pointer; + overflow: hidden; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} +.dispatch-container .cdp-highlight:hover { + transform: translateX(2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); +} + +.dispatch-container .cdp-highlight-rail { + width: 4px; + flex-shrink: 0; + align-self: stretch; +} + +.dispatch-container .cdp-highlight-content { flex: 1; min-width: 0; display: flex; flex-direction: column; - gap: 2px; + gap: 8px; + padding: 12px 14px; +} + +.dispatch-container .cdp-highlight.is-best { + background: linear-gradient(90deg, rgba(16, 185, 129, 0.07) 0%, #fff 65%); + border-color: rgba(16, 185, 129, 0.28); +} +.dispatch-container .cdp-highlight.is-best:hover { + border-color: rgba(16, 185, 129, 0.5); +} +.dispatch-container .cdp-highlight.is-best .cdp-highlight-rail { + background: linear-gradient(180deg, #10b981 0%, #16a34a 100%); +} + +.dispatch-container .cdp-highlight.is-worst { + background: linear-gradient(90deg, rgba(239, 68, 68, 0.07) 0%, #fff 65%); + border-color: rgba(239, 68, 68, 0.28); +} +.dispatch-container .cdp-highlight.is-worst:hover { + border-color: rgba(239, 68, 68, 0.5); +} +.dispatch-container .cdp-highlight.is-worst .cdp-highlight-rail { + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); +} + +.dispatch-container .cdp-highlight-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } .dispatch-container .cdp-highlight-label { display: inline-flex; align-items: center; - gap: 4px; - font-size: 9px; + gap: 6px; + font-size: 11px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; + min-width: 0; } .dispatch-container .cdp-highlight.is-best .cdp-highlight-label { color: #16a34a; } .dispatch-container .cdp-highlight.is-worst .cdp-highlight-label { color: #dc2626; } +.dispatch-container .cdp-highlight-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 7px; + font-size: 14px; + color: #fff; + flex-shrink: 0; +} +.dispatch-container .cdp-highlight.is-best .cdp-highlight-chip { + background: linear-gradient(135deg, #10b981 0%, #16a34a 100%); + box-shadow: 0 1px 3px rgba(16, 185, 129, 0.35); +} +.dispatch-container .cdp-highlight.is-worst .cdp-highlight-chip { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + box-shadow: 0 1px 3px rgba(239, 68, 68, 0.35); +} + +.dispatch-container .cdp-highlight-step-chip { + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #fff; + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15); +} + .dispatch-container .cdp-highlight-title { - font-size: 12px; + font-size: 15px; font-weight: 700; color: #0f172a; + line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + letter-spacing: -0.01em; } .dispatch-container .cdp-highlight-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.dispatch-container .cdp-highlight-pill { + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 999px; font-size: 11px; - font-weight: 600; - color: #64748b; + font-weight: 800; + letter-spacing: 0.01em; + font-variant-numeric: tabular-nums; +} +.dispatch-container .cdp-highlight-pill.is-bad { + background: rgba(239, 68, 68, 0.12); + color: #dc2626; +} +.dispatch-container .cdp-highlight-pill.is-good { + background: rgba(16, 185, 129, 0.12); + color: #16a34a; } /* Trips breakdown ------------------------------------------- */ @@ -6946,11 +7452,11 @@ } .dispatch-container .cdp-seq-status { - font-size: 10px; + font-size: 12px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; - padding: 2px 8px; + padding: 3px 10px; border-radius: 999px; flex-shrink: 0; } @@ -7022,14 +7528,14 @@ } .dispatch-container .cdp-seq-diff-num { - width: 26px; - height: 26px; + width: 30px; + height: 30px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; - font-size: 12px; + font-size: 14px; font-weight: 800; flex-shrink: 0; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7), @@ -7045,7 +7551,7 @@ } .dispatch-container .cdp-seq-diff-title { - font-size: 12px; + font-size: 14px; font-weight: 700; color: #0f172a; overflow: hidden; @@ -7054,15 +7560,15 @@ } .dispatch-container .cdp-seq-diff-sub { - font-size: 11px; + font-size: 13px; font-weight: 600; color: #64748b; } .dispatch-container .cdp-seq-diff-tag { - font-size: 11px; + font-size: 13px; font-weight: 800; - padding: 3px 8px; + padding: 3px 10px; border-radius: 999px; background: rgba(239, 68, 68, 0.12); color: #dc2626; @@ -7073,7 +7579,7 @@ display: inline-flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: 14px; font-weight: 700; color: #16a34a; padding: 6px 10px; @@ -7081,7 +7587,7 @@ border-radius: 8px; } .dispatch-container .cdp-seq-good svg { - font-size: 16px; + font-size: 18px; } /* Cascade-aware sequence diff groups — when N consecutive shifted steps @@ -7123,7 +7629,7 @@ .dispatch-container .cdp-seq-group-num-label { position: relative; color: #fff; - font-size: 11px; + font-size: 13px; font-weight: 800; letter-spacing: -0.01em; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); @@ -7132,9 +7638,9 @@ .dispatch-container .cdp-seq-group-delta { display: inline-flex; align-items: center; - padding: 1px 7px; + padding: 2px 8px; margin-left: 4px; - font-size: 11px; + font-size: 13px; font-weight: 800; border-radius: 999px; background: rgba(99, 102, 241, 0.15); @@ -7203,17 +7709,17 @@ border-radius: 8px; } .dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-num { - width: 22px; - height: 22px; - font-size: 11px; + width: 26px; + height: 26px; + font-size: 13px; } .dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-title { - font-size: 11.5px; + font-size: 13.5px; } .dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-sub { - font-size: 10.5px; + font-size: 12.5px; } .dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-tag { - font-size: 10px; - padding: 2px 6px; + font-size: 12px; + padding: 2px 8px; } \ No newline at end of file diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 220cd30..eb85503 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -180,49 +180,10 @@ function CompareMapClickUnpin({ onUnpin }) { return null; } -// Helper component used by the Compare map — fits the leaflet view to every -// loaded GPS point ONCE per rider (or to just the focused step's coords when -// a step is being scrutinized). Previously the effect depended directly on -// `tracks`, whose identity changes on every parent render — fitBounds ran on -// every render and snapped the map back mid-drag. Now we fit only when the -// fit *signature* changes (rider's delivery IDs, focused step, load count). -function FitMapToTracks({ tracks, focusStep }) { - const map = useMap(); - const fittedForRef = useRef(''); - const trackIdsKey = useMemo( - () => (tracks || []).map((t) => String(t.deliveryid)).sort().join(','), - [tracks] - ); - const loadedCount = useMemo( - () => (tracks || []).filter((t) => (t.coords || []).length > 0).length, - [tracks] - ); - const fitKey = `${trackIdsKey}|step:${focusStep || 'all'}|loaded:${loadedCount}`; - useEffect(() => { - if (loadedCount === 0) return; - if (fittedForRef.current === fitKey) return; - // When a single step is focused, fit ONLY to that delivery's coords so - // the operator can scrutinize the snapped vs. raw path without the rest - // of the day's bounds zooming them out. - const subset = focusStep - ? (tracks || []).filter((t) => t.sequenceStep === focusStep) - : (tracks || []); - const pts = subset.flatMap((t) => (t.coords || []).map((p) => [p.lat, p.lng])); - if (pts.length === 0) return; - try { - const padding = focusStep ? [60, 60] : [40, 40]; - map.fitBounds(pts, { padding, animate: false, maxZoom: focusStep ? 17 : 16 }); - fittedForRef.current = fitKey; - } catch (e) { /* ignore */ } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map, fitKey]); - return null; -} - -// Captures the Leaflet map instance for the parent component via a ref. Used -// by the Compare two-pane sync — the parent needs both map instances so it -// can mirror pans/zooms from one to the other without going through state -// (which would lag a frame and stutter the follower). +// Captures the Leaflet map instance for the parent component via a ref. Kept +// available even after the two-map Compare layout was unified into one map, +// since future per-step imperative zoom logic still needs a handle on the +// planned map instance. function CaptureMap({ targetRef }) { const map = useMap(); useEffect(() => { @@ -233,43 +194,6 @@ function CaptureMap({ targetRef }) { return null; } -// Listens for pan/zoom on the map this component lives inside, and only -// forwards the new view to the sibling map when the user originated the -// motion. Two gates: (1) sourceRef must equal this side — blocks the -// follower side's own moveend (from being setView'd by us) from echoing -// back; (2) the moveend must arrive within USER_GESTURE_WINDOW_MS of the -// last user input event — blocks programmatic flyTo/setView animations -// (auto-fit, focus-step zoom) from masquerading as gestures. -const USER_GESTURE_WINDOW_MS = 800; -function CompareSyncEmitter({ side, sourceRef, lastInputAtRef, onView, enabled }) { - const stamp = () => { - sourceRef.current = side; - lastInputAtRef.current = Date.now(); - }; - useMapEvents({ - mousedown: stamp, - touchstart: stamp, - wheel: stamp, - // drag/zoom fire continuously during a user drag or pinch — keeps the - // input timestamp fresh so the final moveend lands inside the window. - drag: () => { lastInputAtRef.current = Date.now(); }, - zoom: () => { lastInputAtRef.current = Date.now(); }, - moveend: (e) => { - if (!enabled) return; - if (sourceRef.current !== side) return; - if (Date.now() - (lastInputAtRef.current || 0) > USER_GESTURE_WINDOW_MS) return; - onView(e.target); - }, - zoomend: (e) => { - if (!enabled) return; - if (sourceRef.current !== side) return; - if (Date.now() - (lastInputAtRef.current || 0) > USER_GESTURE_WINDOW_MS) return; - onView(e.target); - } - }); - return null; -} - // Haversine distance between two [lat, lng] points in kilometers. Good to // ~0.1% across city scales; we use it to sum the length of an OSRM-snapped // polyline so the Compare delta panel can show "actual km" without depending @@ -731,6 +655,11 @@ const Dispatch = ({ // marker; clicking the marker again unpins it. Stored in a ref because this // only drives imperative leaflet calls — no re-render needed. const pinnedPopupsRef = useRef(new Set()); + // Short-lived close timer for the Compare drop-pin popup. Gives the + // cursor a ~120ms window to travel from the marker onto the popup + // without firing mouseout → closePopup mid-transit. Any new mouseover + // cancels a pending close, so the popup glides instead of flickering. + const compareHoverTimerRef = useRef(null); const isControlled = selectedRiderId !== undefined; const [clock, setClock] = useState(''); @@ -950,6 +879,12 @@ const Dispatch = ({ // per-rider). Kept as a single state flag so we don't entangle it with // viewMode/focused* logic. const [compareOpen, setCompareOpen] = useState(false); + // Which layer the unified compare map renders. + // 'combined' = planned (dashed) + actual GPS (solid) overlaid on one map + // 'planned' = planned route only + // 'actual' = actual GPS tracks only + // Driven by the segmented control overlaid on the compare map's top-left. + const [compareViewMode, setCompareViewMode] = useState('combined'); // Default-open toggle for the "Route sequence" section in the compare data // panel — shows planned vs actual visit order with out-of-order steps flagged. const [sequenceOpen, setSequenceOpen] = useState(true); @@ -978,25 +913,14 @@ const Dispatch = ({ const preCompareCollapsedRef = useRef(false); const prevCompareOpenRef = useRef(false); - // Compare UI v2 — step-focus + map sync. - // focusedCompareStep: null = "overall" (whole day); 1..N = drill into that - // single delivery. Both maps zoom to that step's bounds and the delta - // panel switches from day-summary to per-step planned-vs-actual. - // compareSyncEnabled: when true, panning/zooming either map mirrors the - // view to the other. Toggleable because operators sometimes want to - // park one view and explore the other independently. - // leftMapRef / rightMapRef: Leaflet map instances captured via the - // CaptureMap helper component so the parent can call setView on the - // opposite map directly (no state round-trip = no frame lag). - // syncSourceRef: 'left' | 'right' | null — which side the user is - // currently driving. The follower side ignores moveend events while - // the source side fires them, breaking the ping-pong loop. + // Compare UI — step focus on the unified compare map. + // focusedCompareStep: null = "overall" (whole day); 1..N = drill into + // that single delivery. The unified map zooms to that step's bounds + // and the data panel switches from day-summary to per-step delta. + // leftMapRef: Leaflet map instance captured via CaptureMap — kept so + // future per-step zoom logic can drive the map imperatively. const [focusedCompareStep, setFocusedCompareStep] = useState(null); - const [compareSyncEnabled, setCompareSyncEnabled] = useState(true); const leftMapRef = useRef(null); - const rightMapRef = useRef(null); - const syncSourceRef = useRef(null); - const syncLastInputAtRef = useRef(0); // Stable Canvas renderer for the planned (left) map. Leaflet's default // canvas (used when `preferCanvas` is true) only draws ~10% beyond the @@ -1338,15 +1262,6 @@ const Dispatch = ({ : internalFocusedRider; // Per-rider canvas renderer for the actual (right) map in Compare mode. - // The actual map remounts on rider change (via key={`compare-${rider.id}`}) - // so we recreate the renderer in lock-step to keep its map binding fresh. - // Same padding as the planned map (1.5 → ~4× viewport area) so polylines - // don't visually break at the canvas edge during drag. - const actualMapRenderer = useMemo( - () => L.canvas({ padding: 1.5, tolerance: 5 }), - [focusedRider?.id] - ); - // 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. const handleRiderFocus = useCallback( @@ -1612,21 +1527,9 @@ const Dispatch = ({ useEffect(() => { setFocusedCompareStep(null); setExpandedSeqGroups(new Set()); + setCompareViewMode('combined'); }, [compareOpen, focusedRider?.id]); - // Mirror pan/zoom from whichever map the user is driving. Called by - // CompareSyncEmitter on moveend/zoomend; we read center/zoom off the - // source map and apply them to the other instance with animate:false so - // the follower doesn't visibly catch up half a beat later. - const handleCompareMapView = useCallback((side, sourceMap) => { - if (!compareSyncEnabled) return; - const target = side === 'left' ? rightMapRef.current : leftMapRef.current; - if (!target || !sourceMap) return; - try { - target.setView(sourceMap.getCenter(), sourceMap.getZoom(), { animate: false }); - } catch (e) { /* ignore */ } - }, [compareSyncEnabled]); - // When the focused rider changes, close any open comparison so the operator // doesn't see stale tracks from a previous rider. useEffect(() => { @@ -2101,7 +2004,130 @@ const Dispatch = ({
); + // Shared order-popup body used by both the planned-route number markers + // and the Compare mode actual-track drop pins. Extracted so clicking a + // pin on either layer surfaces the exact same Timeline + Details + KM + // chips — operators learn the layout once and trust it everywhere. + const renderOrderPopupContent = (o) => { + const statusStyle = getStatusStyle(o.orderstatus); + return ( + <> +
+
+
ORDER #{o.orderid}
+ {o.orderstatus && ( + + {statusStyle.label} + + )} +
+
+ {o.rider_name || o.ridername || 'Unassigned'} +
+ {o.deliveryid != null && ( +
Delivery #{o.deliveryid}
+ )} +
+ +
+ {POPUP_TIMELINE.some((t) => o[t.key]) && ( +
+
Timeline
+
+ {POPUP_TIMELINE.map((t) => { + const time = formatTimeOnly(o[t.key]); + if (!time) return null; + return ( +
+ + {t.label} + {time} +
+ ); + })} +
+
+ )} + +
+
Details
+
+ {o.pickupcustomer && ( +
+
+
+
Kitchen
+
{o.pickupcustomer}
+
+
+ )} + {(o.locationname || o.pickuplocation) && ( +
+
+
+
Pickup
+
{o.locationname || o.pickuplocation}
+
+
+ )} + {o.zone_name && ( +
+
+
+
Zone
+
{o.zone_name}
+
+
+ )} + {(o.rider_id || o.userid) && ( +
+
+
+
Rider ID
+
#{o.rider_id || o.userid}
+
+
+ )} +
+ + {(o.kms != null || o.actualkms != null || o.riderkms != null) && ( +
+ {o.kms != null && o.kms !== '' && ( +
+ + Planned + {o.kms} km +
+ )} + {o.actualkms != null && o.actualkms !== '' && ( +
+ + Actual + {o.actualkms} km +
+ )} + {o.riderkms != null && o.riderkms !== '' && ( +
+ + Rider + {parseFloat(o.riderkms).toFixed(2)} km +
+ )} +
+ )} +
+
+ + ); + }; + const renderMarkers = () => { + // Compare "Actual GPS only" view: drop the planned order markers entirely. + // The actual-track drop pins rendered later on the same map carry the + // same step numbers and step-palette colors, so showing both creates + // duplicate, slightly-offset pins that clutter the view. + if (compareOpen && focusedRider && compareViewMode === 'actual') return null; + let ordersToRender = allOrders; if (focusedZone) ordersToRender = focusedZone.orders; if (focusedRider) ordersToRender = focusedRider.orders; @@ -2194,111 +2220,7 @@ const Dispatch = ({ }} > -
-
-
ORDER #{o.orderid}
- {o.orderstatus && ( - - {statusStyle.label} - - )} -
-
- {o.rider_name || o.ridername || 'Unassigned'} -
- {o.deliveryid != null && ( -
Delivery #{o.deliveryid}
- )} -
- -
- {POPUP_TIMELINE.some((t) => o[t.key]) && ( -
-
Timeline
-
- {POPUP_TIMELINE.map((t) => { - const time = formatTimeOnly(o[t.key]); - if (!time) return null; - return ( -
- - {t.label} - {time} -
- ); - })} -
-
- )} - -
-
Details
-
- {o.pickupcustomer && ( -
-
-
-
Kitchen
-
{o.pickupcustomer}
-
-
- )} - {(o.locationname || o.pickuplocation) && ( -
-
-
-
Pickup
-
{o.locationname || o.pickuplocation}
-
-
- )} - {o.zone_name && ( -
-
-
-
Zone
-
{o.zone_name}
-
-
- )} - {(o.rider_id || o.userid) && ( -
-
-
-
Rider ID
-
#{o.rider_id || o.userid}
-
-
- )} -
- - {(o.kms != null || o.actualkms != null || o.riderkms != null) && ( -
- {o.kms != null && o.kms !== '' && ( -
- - Planned - {o.kms} km -
- )} - {o.actualkms != null && o.actualkms !== '' && ( -
- - Actual - {o.actualkms} km -
- )} - {o.riderkms != null && o.riderkms !== '' && ( -
- - Rider - {parseFloat(o.riderkms).toFixed(2)} km -
- )} -
- )} -
-
+ {renderOrderPopupContent(o)}
); @@ -2306,7 +2228,18 @@ const Dispatch = ({ }; const renderRoutes = () => { + // Compare "Actual GPS only" view hides EVERY planned polyline on the + // unified compare map — both the static rendering AND the Animate + // Routes per-pair segments. Without this gate the animation pours the + // planned route on top of the actual GPS in Actual-only mode, which + // is the opposite of what that view exists to show. The actual-track + // polylines render their own progressive draw via animatedActualProgress + // (see riderActualTracks.map inside the MapContainer), so the actual + // animation still plays correctly when planned is hidden. + const hidePlanned = compareOpen && focusedRider && compareViewMode === 'actual'; + if (isAnimating) { + if (hidePlanned) return []; return animatedSegments.map((s, i) => ( )); @@ -2314,6 +2247,7 @@ const Dispatch = ({ const routes = []; const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null; + if (hidePlanned) return routes; riders.forEach(r => { const isActive = activeRiders.has(r.id); if (focusedRider && focusedRider.id !== r.id) return; @@ -2357,7 +2291,16 @@ const Dispatch = ({ const weight = isKitchenView ? 7 : 6; // Aerial fallback (OSRM permanently failed) is rendered dashed so it visually // reads as an estimate vs. an actual routed road polyline. - const dashArray = failed ? '8 6' : undefined; + // In Combined mode (planned + actual overlaid on a single map), the + // planned polyline renders dashed so the operator can visually tell + // it apart from the solid actual-GPS polyline drawn for the same step + // in the same step-palette color. OSRM-failed segments stay dashed + // either way — the existing aerial-fallback signal still wins. + const isCompareTargetForDash = + compareOpen && focusedRider && r.id === focusedRider.id; + const combinedPlannedDash = + isCompareTargetForDash && compareViewMode === 'combined' ? '6 5' : undefined; + const dashArray = failed ? '8 6' : combinedPlannedDash; // Compare mode: recolor the planned polyline per step so the left // (planned) map's segment for delivery X uses the same STEP_PALETTE @@ -3602,15 +3545,6 @@ const Dispatch = ({ {compareOpen && } - {compareOpen && ( - handleCompareMapView('left', m)} - enabled={compareSyncEnabled} - /> - )} {kitchens .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) @@ -3735,12 +3669,302 @@ const Dispatch = ({ ); })} + + {/* Compare mode — actual GPS tracks for the focused rider, drawn + on the same MapContainer as the planned route. Gated by + compareViewMode so the segmented control on the map's top-left + can hide them ("Planned only") or hide the planned polylines + instead ("Actual only"). In Combined mode both render and the + planned polylines switch to a dashed stroke (see renderRoutes) + so the operator can read overlap at a glance. */} + {compareOpen && focusedRider && compareViewMode !== 'planned' && (riderActualTracks.map((t, i) => { + if (t.coords.length === 0) return null; + const color = stepColor(i); + const startPos = [t.coords[0].lat, t.coords[0].lng]; + const endPos = [t.coords[t.coords.length - 1].lat, t.coords[t.coords.length - 1].lng]; + const snapped = osrmTrackRoutes[t.deliveryid]; + const hasRoad = Array.isArray(snapped) && snapped.length >= 2; + const fullPositions = hasRoad + ? snapped + : t.coords.map((p) => [p.lat, p.lng]); + + let positions = fullPositions; + let drawPolyline = true; + if (isAnimating) { + const progress = animatedActualProgress[t.sequenceStep] || 0; + if (progress < 2) { + drawPolyline = false; + } else { + positions = fullPositions.slice(0, Math.min(progress, fullPositions.length)); + } + } + + const isFocusedStep = focusedCompareStep === t.sequenceStep; + const statusLow = String(t.orderstatus || '').toLowerCase(); + const isDelivered = FINAL_STATUSES.has(statusLow); + const isSkipped = SKIPPED_STATUSES.has(statusLow); + const delta = compareDeltas.find((d) => d.sequenceStep === t.sequenceStep); + const isAnomaly = !!delta?.anomaly; + // Look up the corresponding order in the focused rider's set so + // the actual-track drop pin can render the same rich popup the + // planned-route number marker uses (Timeline, Details, KM chips). + // Matched by deliveryid since order.orderid is not in the track + // payload but deliveryid is the join key for /getdeliverylogs. + const orderForTrack = focusedRider?.orders?.find( + (o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid) + ); + + const dropClasses = ['compare-step-pin']; + if (isFocusedStep) dropClasses.push('is-focused'); + if (isDelivered) dropClasses.push('is-delivered'); + if (isSkipped) dropClasses.push('is-skipped'); + if (isAnomaly) dropClasses.push('is-anomaly'); + + const dropPinHtml = + `
` + + `${t.sequenceStep}` + + (isDelivered + ? '' + : '') + + '
'; + const sequenceIcon = L.divIcon({ + className: '', + iconSize: [36, 36], + iconAnchor: [18, 18], + // Lift the popup ~22px above the pin so its tip clears the + // marker icon. Without this the popup centers on the marker, + // its body overlaps the pin, and the cursor moves onto the + // popup → fires mouseout on the marker → popup snaps shut. + popupAnchor: [0, -22], + html: dropPinHtml + }); + + const showStartMarker = t.sequenceStep === 1; + const startIconEl = showStartMarker + ? L.divIcon({ + className: '', + iconSize: [40, 40], + iconAnchor: [20, 20], + html: + `
` + + '
' + }) + : null; + + const handleStepClick = (e) => { + if (e.originalEvent) e.originalEvent.stopPropagation(); + setFocusedCompareStep((prev) => + prev === t.sequenceStep ? null : t.sequenceStep + ); + }; + + // Drop pin click — toggles step focus AND opens the rich order + // popup, so the actual / combined view answers the same "tell me + // about this delivery" question the planned-route number marker + // answers. The pickup (start) pin keeps the focus-only handler; + // there's no per-delivery order modal attached to step 1's + // origin since it's the rider's pickup point, not the drop. + const handleEndMarkerClick = (e) => { + if (e.originalEvent) e.originalEvent.stopPropagation(); + setFocusedCompareStep((prev) => + prev === t.sequenceStep ? null : t.sequenceStep + ); + if (orderForTrack && e.target && typeof e.target.openPopup === 'function') { + e.target.openPopup(); + } + }; + + return ( + + {drawPolyline && ( + + )} + {drawPolyline && ( + + )} + + {showStartMarker && ( + + +
+
+ + + +
+
+ {t.pickupcustomer || 'Pickup'} +
+
+ {t.coords[0]?.logdate + ? `Picked up · ${dayjs(t.coords[0].logdate).format('hh:mm A')}` + : 'Rider trip origin'} +
+
+
+
Click for step 1 details
+
+
+
+ )} + + { + if (compareHoverTimerRef.current) { + clearTimeout(compareHoverTimerRef.current); + compareHoverTimerRef.current = null; + } + e.target.openPopup(); + }, + mouseout: (e) => { + if (focusedCompareStep === t.sequenceStep) return; + const marker = e.target; + if (compareHoverTimerRef.current) { + clearTimeout(compareHoverTimerRef.current); + } + compareHoverTimerRef.current = setTimeout(() => { + marker.closePopup(); + compareHoverTimerRef.current = null; + }, 120); + }, + click: handleEndMarkerClick + } + : { click: handleEndMarkerClick } + } + > + {!orderForTrack && ( + + {(() => { + const ss = getStatusStyle(t.orderstatus); + return ( +
+
+ + {t.sequenceStep} + +
+
+ {t.deliverycustomer || `Step ${t.sequenceStep}`} +
+
+ {t.deliverytime + ? `Delivered ${dayjs(t.deliverytime).format('hh:mm A')}` + : `${t.coords.length} GPS pings`} +
+
+ {t.orderstatus && ( + + {ss.label} + + )} +
+ {isAnomaly && ( +
+ Deviation flagged — see details below +
+ )} +
+ {isFocusedStep ? 'Click to deselect' : 'Click for details'} +
+
+ ); + })()} +
+ )} + {orderForTrack && ( + + {renderOrderPopupContent(orderForTrack)} + + )} +
+
+ ); + }))} - {compareOpen && ( -
- - Planned Route + {compareOpen && focusedRider && ( +
+ + +
)} @@ -3877,17 +4101,6 @@ const Dispatch = ({ Overall )} - -
-
)}