updates on the design and added the riders route page

This commit is contained in:
2026-06-01 15:16:45 +05:30
parent 73b82877b8
commit e662154916
6 changed files with 562 additions and 135 deletions

View File

@@ -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 }
]; ];

View File

@@ -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;

View File

@@ -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 (89 AM, 12 PM4 PM, 7 PM+) intentionally fall outside every batch. // Gaps (89 AM, 12:30 PM4 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 && (

View File

@@ -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) => {

View File

@@ -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>
); );
} }

View File

@@ -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 || ============================================= */}