updates on the design and added the riders route page
This commit is contained in:
@@ -192,7 +192,7 @@ const KPI_META = [
|
|||||||
const BATCH_OPTIONS = [
|
const BATCH_OPTIONS = [
|
||||||
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
|
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
|
||||||
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
|
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
|
||||||
{ id: 'afternoon', label: 'Afternoon Batch', range: '9 AM to 12 PM', color: '#f59e0b', iconKey: 'afternoon', startHour: 9, endHour: 12 },
|
{ id: 'afternoon', label: 'Afternoon Batch', range: '9 AM to 12:30 PM', color: '#f59e0b', iconKey: 'afternoon', startHour: 9, endHour: 12.5 },
|
||||||
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
|
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3786,7 +3786,7 @@
|
|||||||
background: rgba(15, 23, 42, 0.02);
|
background: rgba(15, 23, 42, 0.02);
|
||||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 14px;
|
padding: 18px 18px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.02);
|
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.02);
|
||||||
}
|
}
|
||||||
@@ -3840,16 +3840,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Planned track overrides to align vertically centered since there are no ticks */
|
/* Planned track now also carries a time tick under the circle, so it uses the
|
||||||
.dispatch-container .compare-timeline-track.is-planned .compare-step {
|
same column layout as the actual row (circle stacked above the tick). */
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
|
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
|
||||||
margin-bottom: 0;
|
margin-bottom: 22px;
|
||||||
align-self: center;
|
/* Centers spacer dynamically relative to the 32px circle (matches actual). */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actual track overrides for the spacer alignment */
|
/* Actual track overrides for the spacer alignment */
|
||||||
@@ -6848,7 +6843,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .compare-timeline-container {
|
.dispatch-container .compare-timeline-container {
|
||||||
padding: 6px 10px;
|
padding: 12px 12px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -6894,14 +6889,10 @@
|
|||||||
background: rgba(99, 102, 241, 0.5);
|
background: rgba(99, 102, 241, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Planned track overrides to align vertically centered since there are no ticks */
|
/* Planned track now also carries a time tick under the circle, so the spacer
|
||||||
.dispatch-container .compare-timeline-track.is-planned .compare-step {
|
aligns the same way as the actual row (mirrors the 24px circle center). */
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
|
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
|
||||||
margin-bottom: 0;
|
margin-bottom: 14px;
|
||||||
align-self: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actual track overrides for the spacer alignment */
|
/* Actual track overrides for the spacer alignment */
|
||||||
@@ -7098,7 +7089,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .compare-timeline-container {
|
.dispatch-container .compare-timeline-container {
|
||||||
padding: 6px 10px;
|
padding: 12px 12px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -7124,14 +7115,10 @@
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Planned track overrides to align vertically centered since there are no ticks */
|
/* Planned track now also carries a time tick under the circle, so the spacer
|
||||||
.dispatch-container .compare-timeline-track.is-planned .compare-step {
|
aligns the same way as the actual row (mirrors the 24px circle center). */
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
|
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
|
||||||
margin-bottom: 0;
|
margin-bottom: 14px;
|
||||||
align-self: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actual track overrides for the spacer alignment */
|
/* Actual track overrides for the spacer alignment */
|
||||||
@@ -7312,6 +7299,8 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
||||||
|
transition: grid-template-columns 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container #body.compare-mode #sidebar,
|
.dispatch-container #body.compare-mode #sidebar,
|
||||||
@@ -7319,6 +7308,89 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed-data-panel state — drop the right column entirely so the map
|
||||||
|
claims the full body width. The panel itself is masked via overflow on
|
||||||
|
the body grid; the peek tab below stays visible to re-open. */
|
||||||
|
.dispatch-container #body.compare-mode.compare-data-collapsed {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container #body.compare-mode.compare-data-collapsed .compare-data-panel {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .compare-data-panel {
|
||||||
|
transition: opacity 0.24s ease, transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peek tab for the right-side compare data panel — vertical pill mirroring
|
||||||
|
the left sidebar's toggle, but anchored to the panel's left edge. Tracks
|
||||||
|
the panel by sitting at right:0 when expanded (so it hugs the panel's
|
||||||
|
outside-left edge) and snaps flush to the viewport's right side when
|
||||||
|
collapsed. */
|
||||||
|
.dispatch-container .compare-data-toggle-tab {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
/* Anchor flush against the panel's outside-left edge. Panel max width is
|
||||||
|
440px (see compare-mode grid-template-columns above) plus the 12px grid
|
||||||
|
gap; transform: translate(50%, …) re-centres the 22-wide pill on that
|
||||||
|
boundary so half of it sits on the panel side and half on the map side
|
||||||
|
— same visual treatment as the left sidebar's peek tab. */
|
||||||
|
right: calc(440px + 12px);
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
width: 22px;
|
||||||
|
height: 56px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border, rgba(15, 23, 42, 0.12));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text, #0f172a);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12),
|
||||||
|
0 1px 3px rgba(15, 23, 42, 0.06);
|
||||||
|
z-index: 1200;
|
||||||
|
transition: right 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .compare-data-toggle-tab:hover {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
transform: translate(50%, -50%) scale(1.06);
|
||||||
|
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .compare-data-toggle-tab:focus-visible {
|
||||||
|
outline: 2px solid var(--accent, #3b82f6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .compare-data-toggle-tab.is-collapsed {
|
||||||
|
right: 0;
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
border-radius: 10px 0 0 10px;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .compare-data-toggle-tab.is-collapsed:hover {
|
||||||
|
transform: translate(0, -50%) scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .compare-data-toggle-tab svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header strip — sits above the unified map (row 1, col 1) and
|
/* Header strip — sits above the unified map (row 1, col 1) and
|
||||||
carries the rider title, the step timeline + load progress, and
|
carries the rider title, the step timeline + load progress, and
|
||||||
the layer legend. The Sync toggle was removed when the second
|
the layer legend. The Sync toggle was removed when the second
|
||||||
@@ -7973,6 +8045,12 @@
|
|||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single-column layout stacks the panel BELOW the map, so the
|
||||||
|
side-anchored peek tab no longer makes geometric sense — hide it. */
|
||||||
|
.dispatch-container .compare-data-toggle-tab {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide filter chrome when Compare takes over the screen — view-mode
|
/* Hide filter chrome when Compare takes over the screen — view-mode
|
||||||
@@ -9062,46 +9140,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Top-level Live / Analysis tabs (standalone Dispatch only)
|
Top-level Live / Analysis tabs (pinned inside header, left of profit)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.dispatch-container #dispatch-top-tabs {
|
.dispatch-container #dispatch-top-tabs {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
align-items: center;
|
||||||
padding: 8px 12px 0 12px;
|
gap: 4px;
|
||||||
border-bottom: 1px solid var(--border);
|
padding: 0;
|
||||||
background: var(--bg);
|
background: transparent;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dispatch-container #dispatch-top-tabs.dtt-inline {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.dispatch-container .dtt-tab {
|
.dispatch-container .dtt-tab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 14px;
|
padding: 6px 12px;
|
||||||
border: none;
|
border: 1px solid var(--border);
|
||||||
background: transparent;
|
border-radius: 999px;
|
||||||
font-size: 13px;
|
background: var(--bg);
|
||||||
font-weight: 600;
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
line-height: 1;
|
||||||
margin-bottom: -1px;
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .dtt-tab:hover {
|
.dispatch-container .dtt-tab:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
background: var(--bg-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .dtt-tab.active {
|
.dispatch-container .dtt-tab.active {
|
||||||
color: var(--accent);
|
color: #fff;
|
||||||
border-bottom-color: var(--accent);
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .dtt-icon {
|
.dispatch-container .dtt-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -9477,6 +9562,19 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-rec.da-rec-empty {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-rec.da-rec-empty .da-rec-action {
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dispatch-container .da-rec-head {
|
.dispatch-container .da-rec-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -115,18 +115,18 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF
|
|||||||
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
|
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
|
||||||
// Three named batches, bucketed by assigntime per spec:
|
// Three named batches, bucketed by assigntime per spec:
|
||||||
// • Morning Batch: before 8 AM (00:00 → 08:00)
|
// • Morning Batch: before 8 AM (00:00 → 08:00)
|
||||||
// • Afternoon Batch: 9 AM → 12 PM (09:00 → 12:00)
|
// • Afternoon Batch: 9 AM → 12:30 PM (09:00 → 12:30)
|
||||||
// • Evening Batch: 4 PM → 7 PM (16:00 → 19:00)
|
// • Evening Batch: 4 PM → 7 PM (16:00 → 19:00)
|
||||||
// Gaps (8–9 AM, 12 PM–4 PM, 7 PM+) intentionally fall outside every batch.
|
// Gaps (8–9 AM, 12:30 PM–4 PM, 7 PM+) intentionally fall outside every batch.
|
||||||
const BATCHES_DEFAULT_RAW = [
|
const BATCHES_DEFAULT_RAW = [
|
||||||
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
|
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
|
||||||
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12 },
|
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||||||
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
|
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
|
||||||
];
|
];
|
||||||
|
|
||||||
// v7: three-named-batch layout (Morning / Afternoon / Evening).
|
// v8: afternoon batch extended to 12:30 PM. Bumping from v7 wipes the
|
||||||
// Bumping the key drops cached 5-slot layouts from v6 and earlier.
|
// cached layouts that still hold the old endHour: 12 value.
|
||||||
const SLOTS_STORAGE_KEY = 'dispatch.slots.v7';
|
const SLOTS_STORAGE_KEY = 'dispatch.slots.v8';
|
||||||
|
|
||||||
// Every prior storage key. Wiped once on mount so stale layouts
|
// Every prior storage key. Wiped once on mount so stale layouts
|
||||||
// from earlier code versions can't reappear on the next page load.
|
// from earlier code versions can't reappear on the next page load.
|
||||||
@@ -136,7 +136,8 @@ const LEGACY_SLOTS_STORAGE_KEYS = [
|
|||||||
'dispatch.slots.v3',
|
'dispatch.slots.v3',
|
||||||
'dispatch.slots.v4',
|
'dispatch.slots.v4',
|
||||||
'dispatch.slots.v5',
|
'dispatch.slots.v5',
|
||||||
'dispatch.slots.v6'
|
'dispatch.slots.v6',
|
||||||
|
'dispatch.slots.v7'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
|
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
|
||||||
@@ -611,7 +612,7 @@ const Ico = ({ children }) => (
|
|||||||
// to the X-Batch-Window header value the backend expects.
|
// to the X-Batch-Window header value the backend expects.
|
||||||
const ANALYSIS_BATCH_WINDOWS = [
|
const ANALYSIS_BATCH_WINDOWS = [
|
||||||
{ key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM', sub: 'Early shift orders', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
|
{ key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM', sub: 'Early shift orders', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
|
||||||
{ key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:00 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
|
{ key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:30 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
|
||||||
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
|
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1041,6 +1042,12 @@ const Dispatch = ({
|
|||||||
const preCompareCollapsedRef = useRef(false);
|
const preCompareCollapsedRef = useRef(false);
|
||||||
const prevCompareOpenRef = useRef(false);
|
const prevCompareOpenRef = useRef(false);
|
||||||
|
|
||||||
|
// Compare-data-panel collapse — mirrors the left sidebar peek tab on the
|
||||||
|
// opposite edge so the operator can hide the right rail and let the map
|
||||||
|
// claim the full width during compare. Resets to expanded each time
|
||||||
|
// Compare opens fresh.
|
||||||
|
const [compareDataCollapsed, setCompareDataCollapsed] = useState(false);
|
||||||
|
|
||||||
// Compare UI — step focus on the unified compare map.
|
// Compare UI — step focus on the unified compare map.
|
||||||
// focusedCompareStep: null = "overall" (whole day); 1..N = drill into
|
// focusedCompareStep: null = "overall" (whole day); 1..N = drill into
|
||||||
// that single delivery. The unified map zooms to that step's bounds
|
// that single delivery. The unified map zooms to that step's bounds
|
||||||
@@ -1725,6 +1732,9 @@ const Dispatch = ({
|
|||||||
if (compareOpen && !prevCompareOpenRef.current) {
|
if (compareOpen && !prevCompareOpenRef.current) {
|
||||||
preCompareCollapsedRef.current = sidebarCollapsed;
|
preCompareCollapsedRef.current = sidebarCollapsed;
|
||||||
setSidebarCollapsed(true);
|
setSidebarCollapsed(true);
|
||||||
|
// Fresh compare-open always reveals the data panel — last-collapsed state
|
||||||
|
// shouldn't carry across compare sessions.
|
||||||
|
setCompareDataCollapsed(false);
|
||||||
} else if (!compareOpen && prevCompareOpenRef.current) {
|
} else if (!compareOpen && prevCompareOpenRef.current) {
|
||||||
setSidebarCollapsed(preCompareCollapsedRef.current);
|
setSidebarCollapsed(preCompareCollapsedRef.current);
|
||||||
}
|
}
|
||||||
@@ -2726,6 +2736,24 @@ const Dispatch = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div id="dispatch-top-tabs" className="dtt-inline">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dtt-tab ${topView === 'live' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTopView('live')}
|
||||||
|
>
|
||||||
|
<span className="dtt-icon"><MdMap /></span>
|
||||||
|
Live
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dtt-tab ${topView === 'analysis' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTopView('analysis')}
|
||||||
|
>
|
||||||
|
<span className="dtt-icon"><MdInsights /></span>
|
||||||
|
Analysis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
|
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
|
||||||
@@ -2970,27 +2998,6 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!embedded && (
|
|
||||||
<div id="dispatch-top-tabs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`dtt-tab ${topView === 'live' ? 'active' : ''}`}
|
|
||||||
onClick={() => setTopView('live')}
|
|
||||||
>
|
|
||||||
<span className="dtt-icon"><MdMap /></span>
|
|
||||||
Live
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`dtt-tab ${topView === 'analysis' ? 'active' : ''}`}
|
|
||||||
onClick={() => setTopView('analysis')}
|
|
||||||
>
|
|
||||||
<span className="dtt-icon"><MdInsights /></span>
|
|
||||||
Analysis
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(embedded || topView === 'live') && (<>
|
{(embedded || topView === 'live') && (<>
|
||||||
<div id="strat-row">
|
<div id="strat-row">
|
||||||
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Location'); setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPlace /></span> By Location</button>
|
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Location'); setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPlace /></span> By Location</button>
|
||||||
@@ -3439,7 +3446,7 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div id="body" className={`${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${compareOpen ? 'compare-mode' : ''}`.trim()}>
|
<div id="body" className={`${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${compareOpen ? 'compare-mode' : ''} ${compareOpen && compareDataCollapsed ? 'compare-data-collapsed' : ''}`.trim()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
||||||
@@ -3449,6 +3456,17 @@ const Dispatch = ({
|
|||||||
>
|
>
|
||||||
{sidebarCollapsed ? <MdChevronRight /> : <MdChevronLeft />}
|
{sidebarCollapsed ? <MdChevronRight /> : <MdChevronLeft />}
|
||||||
</button>
|
</button>
|
||||||
|
{compareOpen && focusedRider && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`compare-data-toggle-tab${compareDataCollapsed ? ' is-collapsed' : ''}`}
|
||||||
|
onClick={() => setCompareDataCollapsed((v) => !v)}
|
||||||
|
title={compareDataCollapsed ? 'Show details panel' : 'Hide details panel'}
|
||||||
|
aria-label={compareDataCollapsed ? 'Show details panel' : 'Hide details panel'}
|
||||||
|
>
|
||||||
|
{compareDataCollapsed ? <MdChevronLeft /> : <MdChevronRight />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
|
{/* 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
|
rider is focused, since the focused-rider view already shows that rider's
|
||||||
@@ -4859,12 +4877,18 @@ const Dispatch = ({
|
|||||||
title={
|
title={
|
||||||
`Planned Step ${plannedStepNum}` +
|
`Planned Step ${plannedStepNum}` +
|
||||||
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
|
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
|
||||||
|
(d.expectedTs ? ` · ${d.expectedTs.format('hh:mm A')}` : '') +
|
||||||
(d.anomaly ? ' · deviation flagged' : '')
|
(d.anomaly ? ' · deviation flagged' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="compare-step-circle">
|
<span className="compare-step-circle">
|
||||||
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
|
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
|
||||||
</span>
|
</span>
|
||||||
|
{d.expectedTs && (
|
||||||
|
<span className="compare-step-tick">
|
||||||
|
{d.expectedTs.format('HH:mm')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
@@ -5176,6 +5200,8 @@ const Dispatch = ({
|
|||||||
const riders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : [];
|
const riders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : [];
|
||||||
const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : [];
|
const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : [];
|
||||||
const rec = raw.top_recommendation;
|
const rec = raw.top_recommendation;
|
||||||
|
const hasRecRider = !!(rec && (rec.idle_rider_name || rec.idle_rider_id));
|
||||||
|
const hasRec = !!(rec && rec.action && rec.action !== 'none' && hasRecRider);
|
||||||
const win = raw.window || {};
|
const win = raw.window || {};
|
||||||
|
|
||||||
const fleetMetrics = [
|
const fleetMetrics = [
|
||||||
@@ -5240,7 +5266,7 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rec && (
|
{hasRec ? (
|
||||||
<div className="da-section">
|
<div className="da-section">
|
||||||
<div className="da-section-label">Top Recommendation</div>
|
<div className="da-section-label">Top Recommendation</div>
|
||||||
<div className="da-rec">
|
<div className="da-rec">
|
||||||
@@ -5289,6 +5315,16 @@ const Dispatch = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="da-section">
|
||||||
|
<div className="da-section-label">Top Recommendation</div>
|
||||||
|
<div className="da-rec da-rec-empty">
|
||||||
|
<div className="da-rec-action">
|
||||||
|
<MdInsights />
|
||||||
|
<span>Fleet is balanced, no reassignment needed right now.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{riders.length > 0 && (
|
{riders.length > 0 && (
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const Login = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('firstname')) {
|
if (localStorage.getItem('firstname')) {
|
||||||
navigate('/nearle/orders');
|
navigate('/nearle/dispatch');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ const Login = () => {
|
|||||||
localStorage.setItem('userid', userinfo.userid);
|
localStorage.setItem('userid', userinfo.userid);
|
||||||
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
|
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
|
||||||
fetchAppLocations(userinfo.userid);
|
fetchAppLocations(userinfo.userid);
|
||||||
navigate('/nearle/orders');
|
navigate('/nearle/dispatch');
|
||||||
} else {
|
} else {
|
||||||
OpenToast(res.data.message, 'error', 3000);
|
OpenToast(res.data.message, 'error', 3000);
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ const Login = () => {
|
|||||||
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
|
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
|
||||||
closeGlobalToast(); // to close the pin snackbar
|
closeGlobalToast(); // to close the pin snackbar
|
||||||
|
|
||||||
navigate('/nearle/orders');
|
navigate('/nearle/dispatch');
|
||||||
};
|
};
|
||||||
|
|
||||||
const opentoast = (message) => {
|
const opentoast = (message) => {
|
||||||
|
|||||||
@@ -1,69 +1,285 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { GoogleMap, Polyline, Marker, useJsApiLoader } from '@react-google-maps/api';
|
import { GoogleMap, Polyline, Marker, InfoWindow, useJsApiLoader } from '@react-google-maps/api';
|
||||||
|
import { Box, IconButton, Stack, Typography, CircularProgress } from '@mui/material';
|
||||||
|
import { MdClose, MdRoute } from 'react-icons/md';
|
||||||
|
|
||||||
const containerStyle = {
|
const containerStyle = { width: '100%', height: '100%' };
|
||||||
width: '100%',
|
|
||||||
height: '100%'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RidersRoutes({ details }) {
|
// Renders a single rider's PLANNED route for the date range chosen on the
|
||||||
|
// Riders Summary page. `details` is an ordered array of waypoints (sorted by
|
||||||
|
// the planning step number) shaped as:
|
||||||
|
// { step, orderid, deliveryid, customer, address,
|
||||||
|
// dropLat, dropLng, pickLat, pickLng, expectedTime }
|
||||||
|
// `dropLat/dropLng` are required; pickup coords are optional and rendered as
|
||||||
|
// faded pre-stops if present.
|
||||||
|
export default function RidersRoutes({ details, loading, riderName, dateRange, onClose }) {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
|
const [focusedStep, setFocusedStep] = useState(null);
|
||||||
|
const [routePath, setRoutePath] = useState([]);
|
||||||
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
|
|
||||||
const { isLoaded } = useJsApiLoader({
|
const { isLoaded } = useJsApiLoader({
|
||||||
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_KEY
|
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_KEY
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert dataset
|
// Step-pin coordinates in planning order — what the polyline connects.
|
||||||
const routePath = useMemo(
|
const dropPath = useMemo(
|
||||||
() =>
|
() => (details || []).map((d) => ({ lat: d.dropLat, lng: d.dropLng })),
|
||||||
details?.map((p) => ({
|
|
||||||
lat: Number(p.latitude),
|
|
||||||
lng: Number(p.longitude)
|
|
||||||
})),
|
|
||||||
[details]
|
[details]
|
||||||
);
|
);
|
||||||
const bikeIcon = {
|
|
||||||
path: 'M12 2c-2.2 0-4 1.8-4 4v3H5l-1 2h2l3.6 7.59c.34.58.96.94 1.64.94h2.52c.68 0 1.3-.36 1.64-.94L19 11h2l-1-2h-3V6c0-2.2-1.8-4-4-4z',
|
// Auto-fit map bounds to the full planned path once the map and data are
|
||||||
fillColor: '#9c27b0', // 🔥 purple
|
// both ready. Re-runs whenever the route changes (different rider / date).
|
||||||
fillOpacity: 1,
|
useEffect(() => {
|
||||||
strokeWeight: 0,
|
if (!isLoaded || !mapRef.current || dropPath.length === 0) return;
|
||||||
scale: 1.4,
|
const bounds = new window.google.maps.LatLngBounds();
|
||||||
anchor: new window.google.maps.Point(12, 24)
|
dropPath.forEach((p) => bounds.extend(p));
|
||||||
|
mapRef.current.fitBounds(bounds, 48);
|
||||||
|
}, [isLoaded, dropPath]);
|
||||||
|
|
||||||
|
// Resolve the rider's planned waypoints into an actual road-following path
|
||||||
|
// via the Directions API. Without this, the polyline would cut across
|
||||||
|
// buildings / aerial lines — operators have no way to read the real route.
|
||||||
|
// Directions has a 25-waypoint limit per request, so we chunk and stitch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded || dropPath.length < 2) {
|
||||||
|
setRoutePath([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const ds = new window.google.maps.DirectionsService();
|
||||||
|
const MAX_WPS = 23; // origin + 23 waypoints + destination = 25 stops/chunk
|
||||||
|
|
||||||
|
const fetchSegment = (origin, destination, waypoints) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
ds.route(
|
||||||
|
{
|
||||||
|
origin,
|
||||||
|
destination,
|
||||||
|
waypoints: waypoints.map((p) => ({ location: p, stopover: true })),
|
||||||
|
travelMode: window.google.maps.TravelMode.DRIVING
|
||||||
|
},
|
||||||
|
(result, status) => {
|
||||||
|
if (status === 'OK') resolve(result);
|
||||||
|
else reject(new Error(status));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setRouteLoading(true);
|
||||||
|
try {
|
||||||
|
const points = dropPath;
|
||||||
|
const all = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
const remaining = points.length - 1 - i;
|
||||||
|
const take = Math.min(remaining, MAX_WPS + 1);
|
||||||
|
const origin = points[i];
|
||||||
|
const destination = points[i + take];
|
||||||
|
const waypoints = points.slice(i + 1, i + take);
|
||||||
|
const res = await fetchSegment(origin, destination, waypoints);
|
||||||
|
const seg = res.routes[0].overview_path.map((ll) => ({
|
||||||
|
lat: ll.lat(),
|
||||||
|
lng: ll.lng()
|
||||||
|
}));
|
||||||
|
// Avoid duplicating the join point between adjacent chunks.
|
||||||
|
if (all.length > 0 && seg.length > 0) seg.shift();
|
||||||
|
all.push(...seg);
|
||||||
|
i += take;
|
||||||
|
}
|
||||||
|
if (!cancelled) setRoutePath(all);
|
||||||
|
} catch {
|
||||||
|
// Fall back to the straight-line skeleton on failure (quota, no route, etc.).
|
||||||
|
if (!cancelled) setRoutePath([]);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setRouteLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isLoaded, dropPath]);
|
||||||
|
|
||||||
|
// Numbered step icon as a data URL — drawn fresh per render so we can pass
|
||||||
|
// the step number into the SVG without juggling external assets. Color is
|
||||||
|
// a fixed indigo to match the planned-route polyline below.
|
||||||
|
const stepIcon = (n, isFocused) => {
|
||||||
|
const size = isFocused ? 38 : 32;
|
||||||
|
const color = isFocused ? '#4338ca' : '#6366f1';
|
||||||
|
const svg = encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="${size}" height="${size}">` +
|
||||||
|
`<circle cx="16" cy="16" r="14" fill="${color}" stroke="white" stroke-width="3"/>` +
|
||||||
|
`<text x="16" y="21" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="700" fill="white">${n}</text>` +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
return `data:image/svg+xml;charset=UTF-8,${svg}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto fit bounds
|
const headerBar = (
|
||||||
useEffect(() => {
|
<Stack
|
||||||
if (!mapRef.current || routePath.length === 0) return;
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
const bounds = new window.google.maps.LatLngBounds();
|
spacing={1.5}
|
||||||
routePath.forEach((p) => bounds.extend(p));
|
sx={{
|
||||||
mapRef.current.fitBounds(bounds);
|
px: 2,
|
||||||
}, [routePath]);
|
py: 1.25,
|
||||||
|
borderBottom: '1px solid rgba(15, 23, 42, 0.08)',
|
||||||
if (!isLoaded) return <div>Loading map...</div>;
|
background: 'linear-gradient(135deg, #6366f1 0%, #3b82f6 100%)',
|
||||||
|
color: '#fff',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdRoute size={20} />
|
||||||
|
<Stack sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: 15, lineHeight: 1.2 }}>
|
||||||
|
Planned route{riderName ? ` — ${riderName}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{dateRange && (
|
||||||
|
<Typography sx={{ fontSize: 12, opacity: 0.85 }}>{dateRange}</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{details && details.length > 0 && (
|
||||||
|
<Typography sx={{ fontSize: 12, opacity: 0.9, fontWeight: 600 }}>
|
||||||
|
{details.length} {details.length === 1 ? 'stop' : 'stops'}
|
||||||
|
{routeLoading ? ' · resolving route…' : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ color: '#fff' }} aria-label="Close">
|
||||||
|
<MdClose />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading state — route fetch in flight OR Google Maps script not ready yet.
|
||||||
|
if (loading || !isLoaded) {
|
||||||
return (
|
return (
|
||||||
<GoogleMap mapContainerStyle={containerStyle} onLoad={(map) => (mapRef.current = map)} center={routePath[0]} zoom={16}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
{/* Route line */}
|
{headerBar}
|
||||||
<Polyline
|
<Stack alignItems="center" justifyContent="center" sx={{ flex: 1, gap: 1.5 }}>
|
||||||
path={routePath}
|
<CircularProgress size={32} />
|
||||||
options={{
|
<Typography sx={{ color: '#64748b', fontSize: 13 }}>
|
||||||
strokeColor: '#196fd2',
|
{loading ? 'Loading planned route…' : 'Loading map…'}
|
||||||
strokeOpacity: 0.9,
|
</Typography>
|
||||||
strokeWeight: 5
|
</Stack>
|
||||||
}}
|
</Box>
|
||||||
/>
|
);
|
||||||
|
}
|
||||||
{/* Start marker */}
|
|
||||||
<Marker
|
// Empty state — fetched but rider has no deliveries with drop coords in the
|
||||||
position={routePath[0]}
|
// selected window.
|
||||||
icon={{
|
if (!details || details.length === 0) {
|
||||||
url: 'http://maps.google.com/mapfiles/ms/icons/green-dot.png'
|
return (
|
||||||
}}
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
/>
|
{headerBar}
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ flex: 1, gap: 1, p: 3 }}>
|
||||||
{/* End marker */}
|
<Typography sx={{ color: '#1e293b', fontWeight: 700, fontSize: 16 }}>
|
||||||
<Marker position={routePath[routePath.length - 1]} icon={bikeIcon} />
|
No planned route for this rider
|
||||||
</GoogleMap>
|
</Typography>
|
||||||
|
<Typography sx={{ color: '#64748b', fontSize: 13, textAlign: 'center', maxWidth: 360 }}>
|
||||||
|
There are no deliveries with drop coordinates assigned to this rider for the selected date range.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{headerBar}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<GoogleMap
|
||||||
|
mapContainerStyle={containerStyle}
|
||||||
|
onLoad={(map) => (mapRef.current = map)}
|
||||||
|
center={dropPath[0]}
|
||||||
|
zoom={14}
|
||||||
|
options={{
|
||||||
|
streetViewControl: false,
|
||||||
|
mapTypeControl: false,
|
||||||
|
fullscreenControl: false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{routePath.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Translucent backdrop so the route stays legible on busy tiles. */}
|
||||||
|
<Polyline
|
||||||
|
path={routePath}
|
||||||
|
options={{ strokeColor: '#6366f1', strokeOpacity: 0.25, strokeWeight: 8 }}
|
||||||
|
/>
|
||||||
|
{/* Road-following planned route from the Directions API. */}
|
||||||
|
<Polyline
|
||||||
|
path={routePath}
|
||||||
|
options={{ strokeColor: '#6366f1', strokeOpacity: 0.95, strokeWeight: 4 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Fallback while Directions is in flight (or if it fails) — dashed
|
||||||
|
// straight-line skeleton between drop pins in step order.
|
||||||
|
<Polyline
|
||||||
|
path={dropPath}
|
||||||
|
options={{
|
||||||
|
strokeColor: '#6366f1',
|
||||||
|
strokeOpacity: 0,
|
||||||
|
strokeWeight: 0,
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
icon: {
|
||||||
|
path: 'M 0,-1 0,1',
|
||||||
|
strokeOpacity: 0.6,
|
||||||
|
strokeColor: '#6366f1',
|
||||||
|
scale: 3
|
||||||
|
},
|
||||||
|
offset: '0',
|
||||||
|
repeat: '14px'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details.map((d, i) => {
|
||||||
|
const stepNum = d.step || i + 1;
|
||||||
|
const isFocused = focusedStep === d.deliveryid;
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`step-${d.deliveryid || d.orderid || i}`}
|
||||||
|
position={{ lat: d.dropLat, lng: d.dropLng }}
|
||||||
|
icon={{ url: stepIcon(stepNum, isFocused) }}
|
||||||
|
onClick={() => setFocusedStep(isFocused ? null : d.deliveryid)}
|
||||||
|
zIndex={isFocused ? 1000 : stepNum}
|
||||||
|
>
|
||||||
|
{isFocused && (
|
||||||
|
<InfoWindow onCloseClick={() => setFocusedStep(null)}>
|
||||||
|
<Box sx={{ minWidth: 180, fontFamily: 'inherit' }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, fontSize: 13, color: '#0f172a' }}>
|
||||||
|
Step {stepNum} · {d.customer}
|
||||||
|
</Typography>
|
||||||
|
{d.address && (
|
||||||
|
<Typography sx={{ fontSize: 12, color: '#475569', mt: 0.5 }}>
|
||||||
|
{d.address}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{d.expectedTime && (
|
||||||
|
<Typography sx={{ fontSize: 12, color: '#64748b', mt: 0.5 }}>
|
||||||
|
ETA {String(d.expectedTime).slice(11, 16) || d.expectedTime}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{d.orderid && (
|
||||||
|
<Typography sx={{ fontSize: 11, color: '#94a3b8', mt: 0.5 }}>
|
||||||
|
Order #{d.orderid}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</InfoWindow>
|
||||||
|
)}
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GoogleMap>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ export default function RidersSummary() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [mapOpen, setMapOpen] = useState(false);
|
const [mapOpen, setMapOpen] = useState(false);
|
||||||
const [logDetails, setLogDetails] = useState(null);
|
const [logDetails, setLogDetails] = useState(null);
|
||||||
|
const [selectedRider, setSelectedRider] = useState(null);
|
||||||
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
const [searchword, setSearchword] = useState('');
|
const [searchword, setSearchword] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
|
||||||
@@ -244,15 +246,71 @@ export default function RidersSummary() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| rider delivery logs (for map) ||============================== //
|
// ==============================|| rider planned route (for map) ||============================== //
|
||||||
|
// Pulls every delivery the rider was assigned over the page's date range, then
|
||||||
|
// emits an ordered waypoint list sorted by `step` (the planning sequence). The
|
||||||
|
// map dialog renders this as the rider's PLANNED route — the path the
|
||||||
|
// optimizer told them to follow — not their actual GPS trail.
|
||||||
const getuserdeliverylogs = async (userid) => {
|
const getuserdeliverylogs = async (userid) => {
|
||||||
|
setRouteLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
// /deliveries/getdeliveries treats applocationid=0 differently from a
|
||||||
`${process.env.REACT_APP_URL}/deliveries/getuserdeliverylogs/?userid=${userid}&fromdate=2026-01-28&todate=2026-01-28 `
|
// real location id — when appId===0 ("All") the backend expects the
|
||||||
);
|
// logged-in operator's userid via appuserid instead. Mirrors the
|
||||||
setLogDetails(response.data.details);
|
// branching in api.js#fetchDeliveries.
|
||||||
|
const loggedInUserId = typeof window !== 'undefined' ? localStorage.getItem('userid') || 0 : 0;
|
||||||
|
const scopeParam = appId === 0
|
||||||
|
? `appuserid=${loggedInUserId}`
|
||||||
|
: `applocationid=${appId}`;
|
||||||
|
const url =
|
||||||
|
`${process.env.REACT_APP_URL}/deliveries/getdeliveries/` +
|
||||||
|
`?${scopeParam}` +
|
||||||
|
`&status=all` +
|
||||||
|
`&fromdate=${startdate}` +
|
||||||
|
`&todate=${enddate}` +
|
||||||
|
`&pageno=1` +
|
||||||
|
`&pagesize=200` +
|
||||||
|
`&keyword=` +
|
||||||
|
`&tenantid=` +
|
||||||
|
`&locationid=` +
|
||||||
|
`&userid=${userid}`;
|
||||||
|
const response = await axios.get(url);
|
||||||
|
const rowsRaw = response?.data?.details || [];
|
||||||
|
const toNum = (v) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
const planned = rowsRaw
|
||||||
|
.map((o) => {
|
||||||
|
const dropLat = toNum(o.droplat ?? o.deliverylat);
|
||||||
|
const dropLng = toNum(o.droplon ?? o.deliverylong);
|
||||||
|
const pickLat = toNum(o.pickuplat ?? o.pickuplatitude);
|
||||||
|
const pickLng = toNum(o.pickuplon ?? o.pickuplong ?? o.picklongitude);
|
||||||
|
if (dropLat == null || dropLng == null) return null;
|
||||||
|
return {
|
||||||
|
step: Number(o.step) || 0,
|
||||||
|
orderid: o.orderid,
|
||||||
|
deliveryid: o.deliveryid,
|
||||||
|
customer: o.deliverycustomer || o.customername || `Order ${o.orderid}`,
|
||||||
|
address: o.deliveryaddress || o.deliverysuburb || '',
|
||||||
|
dropLat,
|
||||||
|
dropLng,
|
||||||
|
pickLat: pickLat ?? null,
|
||||||
|
pickLng: pickLng ?? null,
|
||||||
|
// Expected delivery clock — used as a label under the step pin so
|
||||||
|
// the operator can sanity-check sequencing without clicking each
|
||||||
|
// marker.
|
||||||
|
expectedTime: o.expecteddeliverytime || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.step - b.step);
|
||||||
|
setLogDetails(planned);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
OpenToast(err?.message, 'error', 2000);
|
OpenToast(err?.message, 'error', 2000);
|
||||||
|
setLogDetails([]);
|
||||||
|
} finally {
|
||||||
|
setRouteLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -668,10 +726,15 @@ export default function RidersSummary() {
|
|||||||
|
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Stack direction="row" spacing={0.75} justifyContent="flex-end">
|
<Stack direction="row" spacing={0.75} justifyContent="flex-end">
|
||||||
<Tooltip title="View route" placement="top">
|
<Tooltip title="View planned route" placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setSelectedRider({
|
||||||
|
userid: row?.userid,
|
||||||
|
name: `${row?.firstname || ''} ${row?.lastname || ''}`.trim() || `Rider ${row?.userid}`
|
||||||
|
});
|
||||||
|
setLogDetails(null);
|
||||||
setMapOpen(true);
|
setMapOpen(true);
|
||||||
getuserdeliverylogs(row?.userid);
|
getuserdeliverylogs(row?.userid);
|
||||||
}}
|
}}
|
||||||
@@ -921,9 +984,23 @@ export default function RidersSummary() {
|
|||||||
open={mapOpen}
|
open={mapOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setMapOpen(false);
|
setMapOpen(false);
|
||||||
|
setLogDetails(null);
|
||||||
|
setSelectedRider(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>{logDetails && <RidersRoutes details={logDetails} />}</DialogContent>
|
<DialogContent sx={{ p: 0, height: '100vh' }}>
|
||||||
|
<RidersRoutes
|
||||||
|
details={logDetails}
|
||||||
|
loading={routeLoading}
|
||||||
|
riderName={selectedRider?.name}
|
||||||
|
dateRange={`${dayjs(startdate).format('DD/MM/YY')} – ${dayjs(enddate).format('DD/MM/YY')}`}
|
||||||
|
onClose={() => {
|
||||||
|
setMapOpen(false);
|
||||||
|
setLogDetails(null);
|
||||||
|
setSelectedRider(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ============================================= || Date Filter Dialog || ============================================= */}
|
{/* ============================================= || Date Filter Dialog || ============================================= */}
|
||||||
|
|||||||
Reference in New Issue
Block a user