changes made in the dispatch file and css files and added the some filters
This commit is contained in:
@@ -90,6 +90,27 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Operating-city pill — sits to the RIGHT of the "Dispatch" heading inline. */
|
||||
.testing-container .logo-city {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.testing-container .logo-city svg {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.testing-container .hdr-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
@@ -114,6 +135,19 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Header right-cluster — profit/loss + orders pill + date picker, sits to the
|
||||
LEFT of the running clock. Pushed against the clock with margin-left:auto so
|
||||
the .logo on the left stays anchored and the cluster floats right. */
|
||||
.testing-container .hdr-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.testing-container #strat-row {
|
||||
height: 48px;
|
||||
@@ -179,6 +213,88 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Strat-row quick stats — total orders + profit/loss chips next to the view-mode buttons */
|
||||
.testing-container .strat-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Right-floating variant — used for the profit/loss chip when there's no
|
||||
live-controls block to nest inside. */
|
||||
.testing-container .strat-stats.strat-stats-right {
|
||||
margin-left: auto;
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.testing-container .strat-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-orders {
|
||||
background: var(--accent-soft);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-orders .strat-stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-profit {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-profit .strat-stat-value,
|
||||
.testing-container .strat-stat-profit .strat-stat-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-loss {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.testing-container .strat-stat-loss .strat-stat-value,
|
||||
.testing-container .strat-stat-loss .strat-stat-label {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Live data controls (date picker + load status) */
|
||||
.testing-container .live-controls {
|
||||
margin-left: auto;
|
||||
@@ -203,6 +319,13 @@
|
||||
.testing-container .live-status-ready { color: var(--success); }
|
||||
.testing-container .live-status-error { color: #ef4444; }
|
||||
|
||||
.testing-container .live-status-sub {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.testing-container .live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -259,6 +382,42 @@
|
||||
background: var(--bg-sub);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
/* Prevent the row itself from squishing when the chip list overflows. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Horizontal scroller for the slot chips. Keeps the "Slot" label fixed on the
|
||||
left and lets the chip list scroll when it overflows the viewport. */
|
||||
.testing-container .batch-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 116, 139, 0.4) transparent;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.testing-container .batch-scroll::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.testing-container .batch-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.testing-container .batch-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.3);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.testing-container .batch-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.55);
|
||||
}
|
||||
|
||||
.testing-container .batch-label {
|
||||
@@ -268,6 +427,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.testing-container .batch-btn {
|
||||
@@ -284,12 +444,14 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-family: inherit;
|
||||
/* Chips must not shrink — the scroller takes the overflow instead. */
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.testing-container .batch-btn:hover {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.testing-container .batch-btn.active {
|
||||
@@ -298,11 +460,11 @@
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Per-batch active color so each wave reads at a glance */
|
||||
.testing-container .batch-btn.batch-all.active { background: linear-gradient(135deg, #3b82f6, #6366f1); }
|
||||
.testing-container .batch-btn.batch-morning.active { background: linear-gradient(135deg, #f59e0b, #f97316); }
|
||||
.testing-container .batch-btn.batch-lunch.active { background: linear-gradient(135deg, #10b981, #14b8a6); }
|
||||
.testing-container .batch-btn.batch-dinner.active { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
||||
/* Unified active style for hourly slot chips — single accent gradient instead of
|
||||
per-wave colors since 12 different colors would be visually noisy. */
|
||||
.testing-container .batch-btn.batch-slot.active {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
}
|
||||
|
||||
.testing-container .batch-btn-icon {
|
||||
font-size: 14px;
|
||||
@@ -480,6 +642,178 @@
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Sidebar header — moved here from the top bar. Layered card: title row with
|
||||
a scope badge, an area chip, then two stat tiles for orders + riders. */
|
||||
.testing-container .sb-header {
|
||||
position: relative;
|
||||
padding: 18px 18px 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, var(--bg-sub) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.testing-container .sb-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -40px -40px auto auto;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at center, var(--accent-soft) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.testing-container .sb-header > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Top row — title on the left, active-scope badge on the right */
|
||||
.testing-container .sb-header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.testing-container .sb-header-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.testing-container .sb-title-bar {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--accent), #6366f1);
|
||||
}
|
||||
|
||||
.testing-container .sb-title-text {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.testing-container .sb-header-scope {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-sub);
|
||||
border: 1px solid var(--border);
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.testing-container .sb-scope-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Area chip — small location pill */
|
||||
.testing-container .sb-header-area {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.testing-container .sb-area-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Stat tiles — two side-by-side cards, large numerals */
|
||||
.testing-container .sb-header-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.testing-container .sb-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.04);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.testing-container .sb-tile:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.testing-container .sb-tile-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.testing-container .sb-tile-orders .sb-tile-icon {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.testing-container .sb-tile-riders .sb-tile-icon {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--kitchen);
|
||||
}
|
||||
|
||||
.testing-container .sb-tile-body {
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.testing-container .sb-tile-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.testing-container .sb-tile-label {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.testing-container #stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -825,26 +1159,92 @@
|
||||
gap: 16px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.testing-container .step-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.testing-container .step-row.clickable:hover {
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.testing-container .step-row.clickable:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.testing-container .step-row.active {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
box-shadow: inset 3px 0 0 #6366f1;
|
||||
padding-left: 8px;
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
/* Currently-going-on order in the focused-rider view — first non-delivered,
|
||||
non-skipped stop in trip+step order. Light green tint + green accent rail +
|
||||
a small "IN PROGRESS" tag so users see at a glance which delivery is live. */
|
||||
.testing-container .step-row.is-going-on {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
box-shadow: inset 3px 0 0 var(--success);
|
||||
padding-left: 8px;
|
||||
margin-left: -8px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.testing-container .step-row.is-going-on:hover {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
.testing-container .step-row.is-going-on::after {
|
||||
content: 'IN PROGRESS';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.35);
|
||||
animation: going-on-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes going-on-pulse {
|
||||
0%, 100% { box-shadow: 0 2px 6px rgba(34, 197, 94, 0.35); }
|
||||
50% { box-shadow: 0 2px 14px rgba(34, 197, 94, 0.7); }
|
||||
}
|
||||
|
||||
/* When the going-on row is ALSO the focused/clicked stop, keep the green rail
|
||||
(priority signal) but tint a hair stronger so the click state is still felt. */
|
||||
.testing-container .step-row.is-going-on.active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
box-shadow: inset 3px 0 0 var(--success);
|
||||
}
|
||||
|
||||
.testing-container .step-row:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 24px;
|
||||
left: 15px;
|
||||
top: 32px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.testing-container .step-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
@@ -866,6 +1266,19 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.testing-container .step-label-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.testing-container .step-customer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.testing-container .kitchen-tag {
|
||||
color: var(--kitchen);
|
||||
}
|
||||
@@ -889,6 +1302,10 @@
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.testing-container .step-profit.is-loss {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Enriched step row metadata */
|
||||
.testing-container .step-location {
|
||||
font-size: 11px;
|
||||
@@ -1289,6 +1706,97 @@
|
||||
background: var(--kitchen);
|
||||
}
|
||||
|
||||
/* Clickable area chip (button variant) — same look as the chip but with cursor +
|
||||
stronger active state for the currently-expanded suburb. */
|
||||
.testing-container .zone-chip.zone-chip-clickable {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.testing-container .zone-chip.zone-chip-clickable.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.testing-container .zone-chip.zone-chip-clickable.active .zone-chip-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline drill-down panel for a selected suburb */
|
||||
.testing-container .zone-suburb-panel {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
animation: zone-suburb-panel-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes zone-suburb-panel-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.testing-container .zone-suburb-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--accent-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.testing-container .zone-suburb-panel-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.testing-container .zone-suburb-panel-count {
|
||||
margin-left: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.testing-container .zone-suburb-panel-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.testing-container .zone-suburb-panel-close:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.testing-container .zone-suburb-panel-empty {
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.testing-container .kitchen-transition {
|
||||
padding: 12px;
|
||||
background: var(--kitchen-soft);
|
||||
@@ -1403,29 +1911,46 @@
|
||||
/* Markers */
|
||||
.testing-container .cmark {
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
border: 3px solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.testing-container .kitchen-mark {
|
||||
background: var(--kitchen);
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 40px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
/* Focused kitchen marker — larger, brighter, with a pulsing halo so users
|
||||
never lose sight of the kitchen they drilled into. */
|
||||
.testing-container .kitchen-mark.is-focused {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
font-size: 22px;
|
||||
border-width: 4px;
|
||||
animation: kitchen-mark-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes kitchen-mark-pulse {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.9), 0 0 40px rgba(245, 158, 11, 0.5), 0 0 0 0 rgba(245, 158, 11, 0.55); }
|
||||
50% { box-shadow: 0 0 30px rgba(245, 158, 11, 1), 0 0 60px rgba(245, 158, 11, 0.7), 0 0 0 18px rgba(245, 158, 11, 0); }
|
||||
}
|
||||
|
||||
/* Popups - Clean White Look */
|
||||
.testing-container .leaflet-popup-content-wrapper {
|
||||
background: #ffffff;
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, ZoomControl } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import dayjs from 'dayjs';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { MdMap, MdDirectionsBike, MdRestaurant, MdPublic } from 'react-icons/md';
|
||||
import {
|
||||
MdMap,
|
||||
MdDirectionsBike,
|
||||
MdRestaurant,
|
||||
MdPublic,
|
||||
MdInventory2,
|
||||
MdTrendingUp,
|
||||
MdTrendingDown,
|
||||
MdAccountBalanceWallet,
|
||||
MdStraighten,
|
||||
MdLocationOn,
|
||||
MdMarkunreadMailbox,
|
||||
MdMoveToInbox,
|
||||
MdPlace,
|
||||
MdTwoWheeler,
|
||||
MdNotes,
|
||||
MdSwapHoriz
|
||||
} from 'react-icons/md';
|
||||
import { fetchDeliveries } from '../../api/api';
|
||||
import './Dispatch.css';
|
||||
import { RAW_DISPATCH_DATA } from './DispatchData';
|
||||
@@ -37,22 +54,32 @@ const hasValidPickup = (o) => Number.isFinite(toNum(o.pickuplat)) && Number.isFi
|
||||
|
||||
// Batch buckets by expected delivery time-of-day (operator's mental model — morning rush,
|
||||
// lunch wave, dinner wave). Anything outside a window OR with no parsable time falls under "all".
|
||||
const BATCHES = [
|
||||
// { id: 'all', label: 'All', icon: '🍽️' }, // hidden for now — restore for an unfiltered view
|
||||
{ id: 'morning', label: 'Morning', icon: '🌅' },
|
||||
{ id: 'lunch', label: 'Lunch', icon: '🍱' },
|
||||
{ id: 'dinner', label: 'Dinner', icon: '🌙' }
|
||||
];
|
||||
// Hourly delivery slots: 7am→8am … 6pm→7pm. Slot id is `slot-<startHour>` (24h).
|
||||
// To shift the window edit BATCH_START_HOUR / BATCH_END_HOUR (end is exclusive).
|
||||
const BATCH_START_HOUR = 7;
|
||||
const BATCH_END_HOUR = 19;
|
||||
const formatHour12 = (h) => {
|
||||
const period = h >= 12 ? 'pm' : 'am';
|
||||
const hr = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${hr}${period}`;
|
||||
};
|
||||
const BATCHES = Array.from({ length: BATCH_END_HOUR - BATCH_START_HOUR }, (_, i) => {
|
||||
const start = BATCH_START_HOUR + i;
|
||||
return {
|
||||
id: `slot-${start}`,
|
||||
label: `${formatHour12(start)}-${formatHour12(start + 1)}`,
|
||||
startHour: start
|
||||
};
|
||||
});
|
||||
|
||||
const getRowBatch = (r) => {
|
||||
const t = r.expecteddeliverytime || r.deliverydate || r.pickupslot;
|
||||
if (!t) return null;
|
||||
const d = dayjs(t);
|
||||
if (!d.isValid()) return null;
|
||||
const h = d.hour() + d.minute() / 60;
|
||||
if (h < 10.5) return 'morning';
|
||||
if (h < 15) return 'lunch';
|
||||
return 'dinner';
|
||||
const h = d.hour();
|
||||
if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return null;
|
||||
return `slot-${h}`;
|
||||
};
|
||||
|
||||
const FINAL_STATUSES = new Set(['delivered']);
|
||||
@@ -199,6 +226,14 @@ const MapController = ({ focusedItem, viewMode, orders }) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the
|
||||
// SVG vertically centered with the adjacent text and inherits the parent color.
|
||||
const Ico = ({ children }) => (
|
||||
<span className="ico-inline" style={{ display: 'inline-flex', alignItems: 'center', verticalAlign: '-2px', marginRight: 4 }}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const Dispatch = ({
|
||||
data,
|
||||
embedded = false,
|
||||
@@ -218,13 +253,27 @@ const Dispatch = ({
|
||||
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
|
||||
const [focusedKitchen, setFocusedKitchen] = useState(null);
|
||||
const [focusedZone, setFocusedZone] = useState(null);
|
||||
// Suburb chip clicked inside the focused-zone "Areas Covered" section. When set,
|
||||
// an inline drill-down panel lists orders in that suburb directly below the chips.
|
||||
const [selectedSuburb, setSelectedSuburb] = useState(null);
|
||||
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
|
||||
const [focusedStop, setFocusedStop] = useState(null);
|
||||
// Holds leaflet marker instances keyed by orderid so we can imperatively open
|
||||
// their popups when the user clicks a step in the focused-rider sidebar.
|
||||
const orderMarkerRefs = useRef({});
|
||||
const isControlled = selectedRiderId !== undefined;
|
||||
const [clock, setClock] = useState('');
|
||||
const [osrmRoutes, setOsrmRoutes] = useState({});
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [animatedSegments, setAnimatedSegments] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
const [selectedBatch, setSelectedBatch] = useState('morning');
|
||||
// Default to the slot containing the current hour, otherwise the earliest slot.
|
||||
const [selectedBatch, setSelectedBatch] = useState(() => {
|
||||
const h = dayjs().hour();
|
||||
if (h >= BATCH_START_HOUR && h < BATCH_END_HOUR) return `slot-${h}`;
|
||||
return BATCHES[0].id;
|
||||
});
|
||||
const activeBatchRef = useRef(null);
|
||||
|
||||
// Live deliveries query — runs only when no `data` prop is passed (i.e., standalone page).
|
||||
const shouldFetchLive = !data;
|
||||
@@ -258,10 +307,11 @@ const Dispatch = ({
|
||||
// Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay
|
||||
// visible even when a single batch is active).
|
||||
const batchCounts = useMemo(() => {
|
||||
const counts = { all: liveRows.length, morning: 0, lunch: 0, dinner: 0 };
|
||||
const counts = { all: liveRows.length };
|
||||
BATCHES.forEach((b) => { counts[b.id] = 0; });
|
||||
liveRows.forEach((r) => {
|
||||
const b = getRowBatch(r);
|
||||
if (b) counts[b] += 1;
|
||||
if (b) counts[b] = (counts[b] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [liveRows]);
|
||||
@@ -462,6 +512,7 @@ const Dispatch = ({
|
||||
(r) => {
|
||||
if (onRiderSelect) onRiderSelect(r ? r.id : null);
|
||||
if (!isControlled) setInternalFocusedRider(r);
|
||||
setFocusedStop(null);
|
||||
},
|
||||
[isControlled, onRiderSelect]
|
||||
);
|
||||
@@ -499,9 +550,13 @@ const Dispatch = ({
|
||||
|
||||
const fetchRoute = useCallback(async (riderId, tripKey, points) => {
|
||||
const cacheKey = `${riderId}-${tripKey}`;
|
||||
if (osrmRoutes[cacheKey]) return;
|
||||
// Already cached (array) or known-failed (false). `null` means in-flight.
|
||||
if (osrmRoutes[cacheKey] !== undefined) return;
|
||||
if (points.length < 2) return;
|
||||
|
||||
// Mark as in-flight so simultaneous renders don't fire duplicate requests.
|
||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: null }));
|
||||
|
||||
const coords = points.map(p => `${p[1]},${p[0]}`).join(';');
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`;
|
||||
|
||||
@@ -511,9 +566,14 @@ const Dispatch = ({
|
||||
if (data.routes && data.routes[0]) {
|
||||
const poly = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
|
||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly }));
|
||||
} else {
|
||||
// OSRM responded but couldn't route — record as failed so renderRoutes
|
||||
// shows the aerial fallback instead of an empty gap.
|
||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('OSRM Fetch error:', e);
|
||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
|
||||
}
|
||||
}, [osrmRoutes]);
|
||||
|
||||
@@ -566,6 +626,60 @@ const Dispatch = ({
|
||||
});
|
||||
}, [riderPositions, focusedRider, focusedKitchen, activeRiders, fetchRoute]);
|
||||
|
||||
// Auto-advance the selected slot when the wall-clock crosses an hour boundary,
|
||||
// BUT only if the user is still sitting on the previous hour's slot — so a manual
|
||||
// pick (e.g. "let me inspect 9am-10am") is never overridden. Polls every 30s.
|
||||
const prevHourRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!shouldFetchLive) return;
|
||||
if (prevHourRef.current === null) prevHourRef.current = dayjs().hour();
|
||||
const tick = () => {
|
||||
const h = dayjs().hour();
|
||||
if (h === prevHourRef.current) return;
|
||||
const fromSlot = `slot-${prevHourRef.current}`;
|
||||
prevHourRef.current = h;
|
||||
if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return;
|
||||
const toSlot = `slot-${h}`;
|
||||
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
|
||||
};
|
||||
const id = setInterval(tick, 30 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [shouldFetchLive]);
|
||||
|
||||
// Reset focusedStop when the focused kitchen changes so a stale stop from a
|
||||
// previously focused kitchen doesn't linger after switching kitchens.
|
||||
// (For riders, handleRiderFocus already clears focusedStop.)
|
||||
useEffect(() => {
|
||||
setFocusedStop(null);
|
||||
}, [focusedKitchen?.id]);
|
||||
|
||||
// Clear the suburb drill-down when leaving / switching zones so the panel
|
||||
// doesn't pop back open with a stale selection in a different zone.
|
||||
useEffect(() => {
|
||||
setSelectedSuburb(null);
|
||||
}, [focusedZone?.id]);
|
||||
|
||||
// Scroll the active slot chip into the visible part of the horizontal scroller
|
||||
// — used when the default slot is set late in the day and overflows off-screen,
|
||||
// or when the user clicks a chip that's only partially visible.
|
||||
useEffect(() => {
|
||||
const btn = activeBatchRef.current;
|
||||
if (!btn || typeof btn.scrollIntoView !== 'function') return;
|
||||
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}, [selectedBatch]);
|
||||
|
||||
// When the user clicks a step in the focused-rider sidebar (sets focusedStop),
|
||||
// also open that marker's popup so they see the order details without a second click.
|
||||
// Wait one frame so MapController has a chance to recenter first.
|
||||
useEffect(() => {
|
||||
if (!focusedStop) return;
|
||||
const t = setTimeout(() => {
|
||||
const marker = orderMarkerRefs.current[String(focusedStop.orderid)];
|
||||
if (marker && typeof marker.openPopup === 'function') marker.openPopup();
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [focusedStop]);
|
||||
|
||||
const startAnimation = () => {
|
||||
if (isAnimating) {
|
||||
setIsAnimating(false);
|
||||
@@ -630,12 +744,12 @@ const Dispatch = ({
|
||||
});
|
||||
};
|
||||
|
||||
const createKitchenIcon = (name) => L.divIcon({
|
||||
const createKitchenIcon = (name, focused = false) => L.divIcon({
|
||||
className: '',
|
||||
iconSize: [34, 34],
|
||||
iconAnchor: [17, 17],
|
||||
popupAnchor: [0, -18],
|
||||
html: `<div class="kitchen-mark">${(name || 'K').charAt(0).toUpperCase()}</div>`
|
||||
iconSize: focused ? [56, 56] : [46, 46],
|
||||
iconAnchor: focused ? [28, 28] : [23, 23],
|
||||
popupAnchor: [0, focused ? -30 : -24],
|
||||
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}">${(name || 'K').charAt(0).toUpperCase()}</div>`
|
||||
});
|
||||
|
||||
const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569';
|
||||
@@ -644,7 +758,7 @@ const Dispatch = ({
|
||||
const renderRiderCard = (r, i) => (
|
||||
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
|
||||
<div className="rcard-top">
|
||||
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50` }}>🏍</div>
|
||||
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
|
||||
<div className="rcard-info">
|
||||
<div className="rcard-name">{r.riderName}</div>
|
||||
<div className="rcard-zone">{r.orders[0]?.zone_name || 'Coimbatore'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
|
||||
@@ -652,7 +766,7 @@ const Dispatch = ({
|
||||
<div className="rcard-badge" style={{ background: `${r.color}18`, color: r.color }}>{r.orders.length}</div>
|
||||
</div>
|
||||
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (r.orders.length / 15) * 100)}%`, background: r.color }}></div></div>
|
||||
<div className="rcard-meta"><span>📏 {r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span>💰 ₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</span></div>
|
||||
<div className="rcard-meta"><span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span><Ico><MdAccountBalanceWallet /></Ico>₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</span></div>
|
||||
<div className="step-ids">
|
||||
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
|
||||
</div>
|
||||
@@ -673,7 +787,8 @@ const Dispatch = ({
|
||||
|
||||
// Use the 'step' field from data, fallback to index
|
||||
const seq = o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0);
|
||||
const sz = 22;
|
||||
// Bumped from 22 → 32 so the step number reads at city-level zoom.
|
||||
const sz = 32;
|
||||
|
||||
const statusStyle = getStatusStyle(o.orderstatus);
|
||||
const statusLow = String(o.orderstatus || '').toLowerCase();
|
||||
@@ -691,12 +806,25 @@ const Dispatch = ({
|
||||
className: '',
|
||||
iconSize: [sz, sz],
|
||||
iconAnchor: [sz / 2, sz / 2],
|
||||
popupAnchor: [0, -22], // Lift popup above the flag, not just the marker
|
||||
html: `<div class="cmark${isPulsing ? ' pulse' : ''}" style="background:${color};width:${sz}px;height:${sz}px;font-size:${seq > 9 ? 7 : 8}px;opacity:${active ? 1 : 0.75}">${seq > 0 ? seq : ''}${flagSvg}</div>`
|
||||
popupAnchor: [0, -28], // Lift popup above the flag, not just the larger 32px marker
|
||||
html: `<div class="cmark${isPulsing ? ' pulse' : ''}" style="background:${color};width:${sz}px;height:${sz}px;font-size:${seq > 9 ? 12 : 14}px;opacity:${active ? 1 : 0.75}">${seq > 0 ? seq : ''}${flagSvg}</div>`
|
||||
});
|
||||
|
||||
return (
|
||||
<Marker key={o.orderid} position={[parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]} icon={icon} zIndexOffset={rid ? 100 : 0}>
|
||||
<Marker
|
||||
key={o.orderid}
|
||||
position={[parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]}
|
||||
icon={icon}
|
||||
zIndexOffset={rid ? 100 : 0}
|
||||
ref={(inst) => {
|
||||
if (inst) orderMarkerRefs.current[String(o.orderid)] = inst;
|
||||
else delete orderMarkerRefs.current[String(o.orderid)];
|
||||
}}
|
||||
eventHandlers={{
|
||||
mouseover: (e) => e.target.openPopup(),
|
||||
mouseout: (e) => e.target.closePopup()
|
||||
}}
|
||||
>
|
||||
<Popup maxWidth={250}>
|
||||
<div className="pu-id">ORDER #{o.orderid}</div>
|
||||
<div className="pu-rider" style={{ color }}>{o.rider_name || o.ridername || 'Unassigned'}</div>
|
||||
@@ -752,20 +880,29 @@ const Dispatch = ({
|
||||
const roadPoints = osrmRoutes[cacheKey];
|
||||
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||||
|
||||
// Always render the actual road polyline from OSRM — applies to every view
|
||||
// (riders, zones, all routes, kitchens). If OSRM hasn't responded yet we just
|
||||
// don't draw, instead of flashing an aerial line that snaps to the road later.
|
||||
const finalPoints = roadPoints;
|
||||
// Cache values:
|
||||
// Array → OSRM road polyline (use it)
|
||||
// false → OSRM permanently failed (draw aerial fallback so user sees something)
|
||||
// null → request in-flight (DON'T draw anything yet — avoids the aerial flash)
|
||||
// undefined → not yet requested (same as in-flight, wait)
|
||||
const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2;
|
||||
const failed = roadPoints === false;
|
||||
if (!hasRoad && !failed) return; // still loading — don't show aerial flash
|
||||
|
||||
const finalPoints = hasRoad ? roadPoints : buildTripPoints(sorted);
|
||||
if (!finalPoints || finalPoints.length < 2) return;
|
||||
|
||||
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
|
||||
const opacity = isActive ? 1.0 : 0.1;
|
||||
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;
|
||||
|
||||
routes.push(
|
||||
<React.Fragment key={`${r.id}-${tNum}`}>
|
||||
<Polyline positions={finalPoints} pathOptions={{ color: '#ffffff', weight: weight + 4, opacity: opacity * 0.5, lineJoin: 'round', lineCap: 'round' }} />
|
||||
<Polyline positions={finalPoints} pathOptions={{ color: r.color, weight: weight, opacity: opacity, lineJoin: 'round', lineCap: 'round' }} />
|
||||
<Polyline positions={finalPoints} pathOptions={{ color: r.color, weight, opacity, lineJoin: 'round', lineCap: 'round', dashArray }} />
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
@@ -786,85 +923,148 @@ const Dispatch = ({
|
||||
<div id="hdr">
|
||||
<div className="logo">
|
||||
<div className="logo-badge">D</div>
|
||||
<div className="logo-name">Rider <em>Dispatch</em></div>
|
||||
<div className="hdr-sep"></div>
|
||||
<div className="hdr-meta">Coimbatore · {activeStats.orders} orders · {activeStats.riders} {activeStats.riders === 1 ? 'rider' : 'riders'} ({activeStats.label})</div>
|
||||
<div className="logo-name">Dispatch</div>
|
||||
<div className="logo-city"><MdPlace /> Coimbatore</div>
|
||||
</div>
|
||||
|
||||
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
|
||||
Sits to the LEFT of the running clock so the operator sees fleet
|
||||
health + current wave size + selected date together in one row. */}
|
||||
<div className="hdr-stats">
|
||||
{(() => {
|
||||
const isLoss = activeStats.profit < 0;
|
||||
const amount = Math.abs(activeStats.profit);
|
||||
return (
|
||||
<span
|
||||
className={`strat-stat ${isLoss ? 'strat-stat-loss' : 'strat-stat-profit'}`}
|
||||
title={`${isLoss ? 'Loss' : 'Profit'} (${activeStats.label})`}
|
||||
>
|
||||
<span className="strat-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</span>
|
||||
<span className="strat-stat-label">{isLoss ? 'Loss' : 'Profit'}</span>
|
||||
<span className="strat-stat-value">{isLoss ? '-' : ''}₹{amount.toFixed(0)}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
{shouldFetchLive && (
|
||||
<>
|
||||
{liveIsFetching && (
|
||||
<span className="live-status">
|
||||
<span className="live-dot" /> Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''}
|
||||
</span>
|
||||
)}
|
||||
{!liveIsFetching && !liveIsError && (
|
||||
<span className="live-status live-status-ready">
|
||||
<span className="live-dot ready" /> {filteredLiveRows.length} orders
|
||||
{selectedBatch !== 'all' && filteredLiveRows.length !== liveRows.length && (
|
||||
<span className="live-status-sub"> / {liveRows.length} today</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{liveIsError && (
|
||||
<span className="live-status live-status-error">
|
||||
<span className="live-dot error" /> Failed to load
|
||||
</span>
|
||||
)}
|
||||
<label className="live-date-label">
|
||||
<span>Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
max={dayjs().format('YYYY-MM-DD')}
|
||||
onChange={(e) => {
|
||||
setSelectedDate(e.target.value);
|
||||
handleRiderFocus(null);
|
||||
setFocusedKitchen(null);
|
||||
setFocusedZone(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="clock">{clock}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="strat-row">
|
||||
{zoneCards.length > 0 && (
|
||||
<button
|
||||
className={`sbt ${viewMode === 'zones' ? 'active' : ''}`}
|
||||
onClick={() => { setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
|
||||
><span className="sbt-icon"><MdMap /></span> By Zone</button>
|
||||
)}
|
||||
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPlace /></span> By Location</button>
|
||||
<button
|
||||
className={`sbt ${viewMode === 'zones' ? 'active' : ''}`}
|
||||
onClick={() => { setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
|
||||
><span className="sbt-icon"><MdMap /></span> By Zone</button>
|
||||
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
|
||||
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdRestaurant /></span> By Kitchen</button>
|
||||
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span> All Routes</button>
|
||||
|
||||
{shouldFetchLive && (
|
||||
<div className="live-controls">
|
||||
{liveIsFetching && (
|
||||
<span className="live-status">
|
||||
<span className="live-dot" /> Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''}
|
||||
</span>
|
||||
)}
|
||||
{!liveIsFetching && !liveIsError && (
|
||||
<span className="live-status live-status-ready">
|
||||
<span className="live-dot ready" /> {liveRows.length} orders
|
||||
</span>
|
||||
)}
|
||||
{liveIsError && (
|
||||
<span className="live-status live-status-error">
|
||||
<span className="live-dot error" /> Failed to load
|
||||
</span>
|
||||
)}
|
||||
<label className="live-date-label">
|
||||
<span>Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
max={dayjs().format('YYYY-MM-DD')}
|
||||
onChange={(e) => {
|
||||
setSelectedDate(e.target.value);
|
||||
handleRiderFocus(null);
|
||||
setFocusedKitchen(null);
|
||||
setFocusedZone(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldFetchLive && (
|
||||
<div id="batch-row">
|
||||
<span className="batch-label">Batch</span>
|
||||
{BATCHES.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
className={`batch-btn batch-${b.id} ${selectedBatch === b.id ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedBatch(b.id);
|
||||
handleRiderFocus(null);
|
||||
setFocusedKitchen(null);
|
||||
setFocusedZone(null);
|
||||
}}
|
||||
title={`${b.label} batch`}
|
||||
>
|
||||
<span className="batch-btn-icon">{b.icon}</span>
|
||||
<span className="batch-btn-label">{b.label}</span>
|
||||
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="batch-label">Slot</span>
|
||||
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
|
||||
horizontally when it overflows. */}
|
||||
<div className="batch-scroll">
|
||||
{BATCHES.map((b) => {
|
||||
const isActive = selectedBatch === b.id;
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
ref={isActive ? activeBatchRef : null}
|
||||
className={`batch-btn batch-slot ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedBatch(b.id);
|
||||
handleRiderFocus(null);
|
||||
setFocusedKitchen(null);
|
||||
setFocusedZone(null);
|
||||
}}
|
||||
title={`${b.label} slot`}
|
||||
>
|
||||
<span className="batch-btn-label">{b.label}</span>
|
||||
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="body">
|
||||
<div id="sidebar">
|
||||
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
|
||||
rider is focused, since the focused-rider view already shows that rider's
|
||||
stats prominently (name + Orders/Distance/Profit tiles). */}
|
||||
{!focusedRider && (
|
||||
<div className="sb-header">
|
||||
<div className="sb-header-top">
|
||||
<div className="sb-header-title">
|
||||
<span className="sb-title-bar" aria-hidden="true" />
|
||||
<span className="sb-title-text">RIDER DISPATCH</span>
|
||||
</div>
|
||||
<span className="sb-header-scope" title={activeStats.label}>
|
||||
<span className="sb-scope-dot" />
|
||||
{activeStats.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sb-header-tiles">
|
||||
<div className="sb-tile sb-tile-orders">
|
||||
<span className="sb-tile-icon"><MdInventory2 /></span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{activeStats.orders}</div>
|
||||
<div className="sb-tile-label">{activeStats.orders === 1 ? 'Order' : 'Orders'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-tile sb-tile-riders">
|
||||
<span className="sb-tile-icon"><MdTwoWheeler /></span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{activeStats.riders}</div>
|
||||
<div className="sb-tile-label">{activeStats.riders === 1 ? 'Rider' : 'Riders'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats strip hidden for now — restore by removing this comment wrapper.
|
||||
<div id="stats-strip">
|
||||
<div className="sc"><div className="sc-lbl">Orders</div><div className="sc-val g">{activeStats.orders}</div><div className="sc-sub">{activeStats.label}</div></div>
|
||||
@@ -887,17 +1087,17 @@ const Dispatch = ({
|
||||
return (
|
||||
<div className="rd-stats-grid">
|
||||
<div className="rd-stat rd-stat-orders">
|
||||
<div className="rd-stat-icon">📦</div>
|
||||
<div className="rd-stat-icon"><MdInventory2 /></div>
|
||||
<div className="rd-stat-value">{focusedRider.orders.length}</div>
|
||||
<div className="rd-stat-label">Orders</div>
|
||||
</div>
|
||||
<div className="rd-stat rd-stat-distance">
|
||||
<div className="rd-stat-icon">📏</div>
|
||||
<div className="rd-stat-icon"><MdStraighten /></div>
|
||||
<div className="rd-stat-value">{totalKm.toFixed(1)}<span className="rd-stat-unit">km</span></div>
|
||||
<div className="rd-stat-label">Distance</div>
|
||||
</div>
|
||||
<div className={`rd-stat rd-stat-profit ${isLoss ? 'is-loss' : 'is-gain'}`}>
|
||||
<div className="rd-stat-icon">{isLoss ? '📉' : '📈'}</div>
|
||||
<div className="rd-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</div>
|
||||
<div className="rd-stat-value">
|
||||
{isLoss ? '-' : ''}₹{Math.abs(totalProfit).toFixed(0)}
|
||||
</div>
|
||||
@@ -913,6 +1113,20 @@ const Dispatch = ({
|
||||
if (!trips[t]) trips[t] = [];
|
||||
trips[t].push(o);
|
||||
});
|
||||
// Identify the rider's currently-going-on order — first non-final,
|
||||
// non-skipped stop in (trip, step) order. Highlighted in light green
|
||||
// so users see at a glance which delivery is in progress.
|
||||
const sortedAll = [...focusedRider.orders].sort((a, b) => {
|
||||
const tA = a.trip_number || 1;
|
||||
const tB = b.trip_number || 1;
|
||||
if (tA !== tB) return tA - tB;
|
||||
return (a.step || 0) - (b.step || 0);
|
||||
});
|
||||
const activeOrder = sortedAll.find((o) => {
|
||||
const s = String(o.orderstatus || '').toLowerCase();
|
||||
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
|
||||
});
|
||||
const activeOrderId = activeOrder ? activeOrder.orderid : null;
|
||||
let prevKitchenKey = null;
|
||||
return Object.entries(trips)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
@@ -921,8 +1135,8 @@ const Dispatch = ({
|
||||
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
|
||||
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
|
||||
<span className="trip-stats">
|
||||
<span>📍 {tOrders.length} stops</span>
|
||||
<span>📏 {tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
|
||||
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
|
||||
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="step-wrap">
|
||||
@@ -933,22 +1147,41 @@ const Dispatch = ({
|
||||
return (
|
||||
<React.Fragment key={o.orderid}>
|
||||
{showTransition && (
|
||||
<div className="kitchen-transition"><span className="kt-ico">🔄</span> Switch to <strong>{o.pickupcustomer}</strong></div>
|
||||
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
|
||||
)}
|
||||
{idx === 0 && (
|
||||
<div className="step-row">
|
||||
<div className="step-col-left"><div className="step-dot kitchen">K</div></div>
|
||||
<div className="step-col-body">
|
||||
<div className="step-label"><span className="kitchen-tag">📥 {o.pickupcustomer}</span></div>
|
||||
<div className="step-label"><span className="kitchen-tag"><Ico><MdMoveToInbox /></Ico>{o.pickupcustomer}</span></div>
|
||||
<div className="step-dest">Pickup point · Trip {tNum}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="step-row">
|
||||
{(() => {
|
||||
const isActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||
const isGoingOn = activeOrderId && o.orderid === activeOrderId;
|
||||
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||
return (
|
||||
<div
|
||||
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''} ${isGoingOn ? 'is-going-on' : ''}`}
|
||||
role={canFocus ? 'button' : undefined}
|
||||
tabIndex={canFocus ? 0 : undefined}
|
||||
onClick={canFocus ? () => setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
|
||||
onKeyDown={canFocus ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon });
|
||||
}
|
||||
} : undefined}
|
||||
title={canFocus ? (isActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
|
||||
>
|
||||
<div className="step-col-left"><div className="step-dot delivery">{o.step || idx + 1}</div></div>
|
||||
<div className="step-col-body">
|
||||
<div className="step-label" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>📬 {o.deliverycustomer}</span>
|
||||
<div className="step-label step-label-row">
|
||||
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
|
||||
{o.orderstatus && (() => {
|
||||
const s = getStatusStyle(o.orderstatus);
|
||||
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
|
||||
@@ -967,15 +1200,28 @@ const Dispatch = ({
|
||||
})()}
|
||||
</div>
|
||||
<div className="step-dest">Order #{o.orderid}</div>
|
||||
{(o.locationname || o.locationsuburb) && (
|
||||
<div className="step-location">📍 {[o.locationname, o.locationsuburb].filter(Boolean).join(' · ')}</div>
|
||||
{/* Show the customer's delivery address rather than the kitchen's
|
||||
pickup location (locationname/locationsuburb) — the kitchen is
|
||||
already implied by the surrounding trip header. */}
|
||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
||||
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
|
||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
||||
</div>
|
||||
)}
|
||||
{o.ordernotes && (
|
||||
<div className="step-notes" title={o.ordernotes}>📝 {o.ordernotes}</div>
|
||||
<div className="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
|
||||
)}
|
||||
<div className="step-detail">
|
||||
<span>📏 {o.actualkms || o.kms || 0} km</span>
|
||||
<span className="step-profit">💰 ₹{parseFloat(o.profit || 0).toFixed(0)}</span>
|
||||
<span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
|
||||
{(() => {
|
||||
const p = parseFloat(o.profit || 0);
|
||||
const isLoss = p < 0;
|
||||
return (
|
||||
<span className={`step-profit ${isLoss ? 'is-loss' : ''}`}>
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(p).toFixed(0)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{o.deliverycharge != null && (
|
||||
<span className="step-charges">₹{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
|
||||
)}
|
||||
@@ -985,6 +1231,8 @@ const Dispatch = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -997,16 +1245,34 @@ const Dispatch = ({
|
||||
<>
|
||||
<div className="rd-rider-name" style={{ color: '#f59e0b' }}>{focusedKitchen.kitchenName}</div>
|
||||
<div className="rd-rider-sub">
|
||||
<span>📦 {focusedKitchen.orders.length} orders</span>
|
||||
<span>🏍 {focusedKitchen.riders.size} riders</span>
|
||||
<span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
|
||||
<span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
|
||||
</div>
|
||||
<div className="step-wrap">
|
||||
{focusedKitchen.orders.map((o, idx) => (
|
||||
<div key={o.orderid} className="step-row">
|
||||
<div className="step-col-left"><div className="step-dot delivery" style={{ background: getRiderColor(o.rider_id) }}>{idx + 1}</div></div>
|
||||
{focusedKitchen.orders.map((o, idx) => {
|
||||
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||
const isActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||
return (
|
||||
<div
|
||||
key={o.orderid}
|
||||
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''}`}
|
||||
role={canFocus ? 'button' : undefined}
|
||||
tabIndex={canFocus ? 0 : undefined}
|
||||
onClick={canFocus ? () => setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
|
||||
onKeyDown={canFocus ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon });
|
||||
}
|
||||
} : undefined}
|
||||
title={canFocus ? (isActive ? 'Click to show full kitchen view' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
|
||||
>
|
||||
<div className="step-col-left"><div className="step-dot delivery" style={{ background: getRiderColor(o.rider_id), color: '#fff', borderColor: getRiderColor(o.rider_id) }}>{idx + 1}</div></div>
|
||||
<div className="step-col-body">
|
||||
<div className="step-label" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>📬 {o.deliverycustomer}</span>
|
||||
<div className="step-label step-label-row">
|
||||
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
|
||||
{o.orderstatus && (() => {
|
||||
const s = getStatusStyle(o.orderstatus);
|
||||
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
|
||||
@@ -1025,15 +1291,29 @@ const Dispatch = ({
|
||||
})()}
|
||||
</div>
|
||||
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div>
|
||||
{(o.locationname || o.locationsuburb) && (
|
||||
<div className="step-location">📍 {[o.locationname, o.locationsuburb].filter(Boolean).join(' · ')}</div>
|
||||
{/* In the By-Kitchen view we show the customer's delivery address,
|
||||
not the kitchen's location (locationname/locationsuburb describe
|
||||
the pickup spot, which is redundant when the kitchen is already
|
||||
the focused context). */}
|
||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
||||
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
|
||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
||||
</div>
|
||||
)}
|
||||
{o.ordernotes && (
|
||||
<div className="step-notes" title={o.ordernotes}>📝 {o.ordernotes}</div>
|
||||
<div className="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
|
||||
)}
|
||||
<div className="step-detail">
|
||||
<span>📏 {o.actualkms || o.kms || 0} km</span>
|
||||
<span className="step-profit">💰 ₹{parseFloat(o.profit || 0).toFixed(0)}</span>
|
||||
<span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
|
||||
{(() => {
|
||||
const p = parseFloat(o.profit || 0);
|
||||
const isLoss = p < 0;
|
||||
return (
|
||||
<span className={`step-profit ${isLoss ? 'is-loss' : ''}`}>
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(p).toFixed(0)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{o.deliverycharge != null && (
|
||||
<span className="step-charges">₹{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
|
||||
)}
|
||||
@@ -1043,7 +1323,8 @@ const Dispatch = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1053,10 +1334,12 @@ const Dispatch = ({
|
||||
<button className="rd-back" onClick={() => setFocusedZone(null)}>← Back to zones</button>
|
||||
<div className="rd-rider-name" style={{ color: '#3b82f6' }}>{focusedZone.name}</div>
|
||||
<div className="rd-rider-sub">
|
||||
<span>📦 {focusedZone.totalOrders} orders</span>
|
||||
<span>🏍 {focusedZone.activeRidersCount} riders</span>
|
||||
<span>📏 {focusedZone.totalKms.toFixed(1)} km</span>
|
||||
<span className="step-profit">💰 ₹{focusedZone.totalProfit.toFixed(0)}</span>
|
||||
<span><Ico><MdInventory2 /></Ico>{focusedZone.totalOrders} orders</span>
|
||||
<span><Ico><MdTwoWheeler /></Ico>{focusedZone.activeRidersCount} riders</span>
|
||||
<span><Ico><MdStraighten /></Ico>{focusedZone.totalKms.toFixed(1)} km</span>
|
||||
<span className={`step-profit ${focusedZone.totalProfit < 0 ? 'is-loss' : ''}`}>
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{focusedZone.totalProfit < 0 ? '-' : ''}₹{Math.abs(focusedZone.totalProfit).toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status breakdown */}
|
||||
@@ -1093,18 +1376,100 @@ const Dispatch = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Areas covered (delivery suburbs) */}
|
||||
{/* Areas covered (delivery suburbs) — clicking a chip drills down to that
|
||||
suburb's orders in an inline panel below the chip row. */}
|
||||
{focusedZone.suburbs.length > 0 && (
|
||||
<div className="zone-detail-section">
|
||||
<div className="zone-section-label">Areas Covered <span className="section-count">({focusedZone.suburbs.length})</span></div>
|
||||
<div className="zone-chips">
|
||||
{focusedZone.suburbs.map((s) => (
|
||||
<span key={s.name} className="zone-chip">
|
||||
<span className="zone-chip-name">{s.name}</span>
|
||||
<span className="zone-chip-count">{s.count}</span>
|
||||
</span>
|
||||
))}
|
||||
{focusedZone.suburbs.map((s) => {
|
||||
const isActive = selectedSuburb === s.name;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={s.name}
|
||||
className={`zone-chip zone-chip-clickable ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSelectedSuburb(isActive ? null : s.name)}
|
||||
title={isActive ? 'Click again to close' : `Show ${s.count} order${s.count === 1 ? '' : 's'} in ${s.name}`}
|
||||
>
|
||||
<span className="zone-chip-name">{s.name}</span>
|
||||
<span className="zone-chip-count">{s.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedSuburb && (() => {
|
||||
// Suburb strings in the source data can have trailing whitespace
|
||||
// and inconsistent casing (e.g. "uppilipalayam "). The tally that
|
||||
// builds chip names keeps them as-is, so normalize BOTH sides to
|
||||
// a trimmed lowercase key when filtering.
|
||||
const norm = (v) => String(v || '').trim().toLowerCase();
|
||||
const target = norm(selectedSuburb);
|
||||
const suburbOrders = focusedZone.orders.filter((o) =>
|
||||
norm(o.deliverysuburb || o.locationsuburb) === target
|
||||
);
|
||||
return (
|
||||
<div className="zone-suburb-panel">
|
||||
<div className="zone-suburb-panel-head">
|
||||
<div className="zone-suburb-panel-title">
|
||||
<Ico><MdLocationOn /></Ico>{selectedSuburb}
|
||||
<span className="zone-suburb-panel-count">{suburbOrders.length} {suburbOrders.length === 1 ? 'order' : 'orders'}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="zone-suburb-panel-close"
|
||||
onClick={() => setSelectedSuburb(null)}
|
||||
title="Close"
|
||||
aria-label="Close suburb panel"
|
||||
>×</button>
|
||||
</div>
|
||||
{suburbOrders.length === 0 ? (
|
||||
<div className="zone-suburb-panel-empty">No orders in this area.</div>
|
||||
) : (
|
||||
<div className="step-wrap">
|
||||
{suburbOrders.map((o, idx) => {
|
||||
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||
const riderColor = getRiderColor(o.rider_id);
|
||||
const statusStyle = getStatusStyle(o.orderstatus);
|
||||
return (
|
||||
<div
|
||||
key={o.orderid}
|
||||
className={`step-row ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''}`}
|
||||
role={canFocus ? 'button' : undefined}
|
||||
tabIndex={canFocus ? 0 : undefined}
|
||||
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
|
||||
>
|
||||
<div className="step-col-left">
|
||||
<div className="step-dot delivery" style={{ background: riderColor, color: '#fff', borderColor: riderColor }}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="step-col-body">
|
||||
<div className="step-label step-label-row">
|
||||
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || '—'}</span>
|
||||
{o.orderstatus && (
|
||||
<span className="step-flag-label" style={{ color: statusStyle.bg }}>{statusStyle.label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername || '—'}</div>
|
||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
||||
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
|
||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1115,7 +1480,7 @@ const Dispatch = ({
|
||||
<div className="zone-chips">
|
||||
{focusedZone.kitchens.map((k) => (
|
||||
<span key={k.name} className="zone-chip kitchen">
|
||||
<span className="zone-chip-name">🍳 {k.name}</span>
|
||||
<span className="zone-chip-name"><Ico><MdRestaurant /></Ico>{k.name}</span>
|
||||
<span className="zone-chip-count kitchen">{k.count}</span>
|
||||
</span>
|
||||
))}
|
||||
@@ -1147,7 +1512,7 @@ const Dispatch = ({
|
||||
return (
|
||||
<div key={z.id} className="rcard zone-card" onClick={() => setFocusedZone(z)} style={{ animationDelay: `${i * 0.05}s` }}>
|
||||
<div className="zone-card-header">
|
||||
<div className="zone-card-emoji">🗺️</div>
|
||||
<div className="zone-card-emoji"><MdMap /></div>
|
||||
<div className="zone-card-titles">
|
||||
<div className="zone-card-name">{z.name}</div>
|
||||
<div className="zone-card-sub">
|
||||
@@ -1187,22 +1552,22 @@ const Dispatch = ({
|
||||
{/* Stat pills */}
|
||||
<div className="zone-stat-pills">
|
||||
<span className="zone-stat-pill" title="Areas covered">
|
||||
<span className="zone-stat-icon">📍</span>
|
||||
<span className="zone-stat-icon"><MdLocationOn /></span>
|
||||
<span className="zone-stat-value">{z.suburbs.length}</span>
|
||||
<span className="zone-stat-label">{z.suburbs.length === 1 ? 'area' : 'areas'}</span>
|
||||
</span>
|
||||
<span className="zone-stat-pill" title="Total distance">
|
||||
<span className="zone-stat-icon">📏</span>
|
||||
<span className="zone-stat-icon"><MdStraighten /></span>
|
||||
<span className="zone-stat-value">{z.totalKms.toFixed(1)}</span>
|
||||
<span className="zone-stat-label">km</span>
|
||||
</span>
|
||||
<span className="zone-stat-pill" title="Kitchens">
|
||||
<span className="zone-stat-icon">🍳</span>
|
||||
<span className="zone-stat-icon"><MdRestaurant /></span>
|
||||
<span className="zone-stat-value">{z.kitchens.length}</span>
|
||||
<span className="zone-stat-label">{z.kitchens.length === 1 ? 'kitchen' : 'kitchens'}</span>
|
||||
</span>
|
||||
<span className={`zone-stat-pill ${profitNeg ? 'profit-negative' : 'profit-positive'}`} title="Total profit">
|
||||
<span className="zone-stat-icon">💰</span>
|
||||
<span className="zone-stat-icon"><MdAccountBalanceWallet /></span>
|
||||
<span className="zone-stat-value">
|
||||
{profitNeg ? `-₹${Math.abs(z.totalProfit).toFixed(0)}` : `₹${z.totalProfit.toFixed(0)}`}
|
||||
</span>
|
||||
@@ -1226,15 +1591,15 @@ const Dispatch = ({
|
||||
kitchens.map((k, i) => (
|
||||
<div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}>
|
||||
<div className="rcard-top">
|
||||
<div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50' }}>🍳</div>
|
||||
<div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div>
|
||||
<div className="rcard-info">
|
||||
<div className="rcard-name">{k.kitchenName}</div>
|
||||
<div className="rcard-zone">{k.riders.size} riders · 💰 ₹{k.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</div>
|
||||
<div className="rcard-zone">{k.riders.size} riders · <Ico><MdAccountBalanceWallet /></Ico>₹{k.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</div>
|
||||
</div>
|
||||
<div className="rcard-badge" style={{ background: '#f59e0b18', color: '#f59e0b' }}>{k.orders.length}</div>
|
||||
</div>
|
||||
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (k.orders.length / 20) * 100)}%`, background: '#f59e0b' }}></div></div>
|
||||
<div className="rcard-meta"><span>📏 {k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span>{k.riders.size} riders</span></div>
|
||||
<div className="rcard-meta"><span><Ico><MdStraighten /></Ico>{k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span>{k.riders.size} riders</span></div>
|
||||
<div className="step-ids">
|
||||
{Array.from(k.riders).slice(0, 10).map(rid => (
|
||||
<span key={rid} className="step-id" style={{ color: getRiderColor(rid) }}>{riders.find(r => r.id === rid)?.riderName.split(' ')[0]}</span>
|
||||
@@ -1257,7 +1622,7 @@ const Dispatch = ({
|
||||
<MapContainer center={[11.022, 76.982]} zoom={12} scrollWheelZoom style={{ height: '100%', width: '100%' }} zoomControl={false}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' />
|
||||
<ZoomControl position="bottomright" />
|
||||
<MapController focusedItem={focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} />
|
||||
<MapController focusedItem={((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} />
|
||||
{kitchens
|
||||
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
||||
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
|
||||
@@ -1265,8 +1630,8 @@ const Dispatch = ({
|
||||
<Marker
|
||||
key={`k-${i}`}
|
||||
position={[k.lat, k.lon]}
|
||||
icon={createKitchenIcon(k.kitchenName)}
|
||||
zIndexOffset={2000}
|
||||
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id)}
|
||||
zIndexOffset={focusedKitchen?.id === k.id ? 4000 : 2000}
|
||||
eventHandlers={{
|
||||
click: () => setFocusedKitchen(k),
|
||||
mouseover: (e) => e.target.openPopup(),
|
||||
@@ -1312,7 +1677,11 @@ const Dispatch = ({
|
||||
position={pos}
|
||||
icon={bikeIcon}
|
||||
zIndexOffset={3000}
|
||||
eventHandlers={{ click: () => handleRiderFocus(riders.find((r) => r.id === p.id) || null) }}
|
||||
eventHandlers={{
|
||||
click: () => handleRiderFocus(riders.find((r) => r.id === p.id) || null),
|
||||
mouseover: (e) => e.target.openPopup(),
|
||||
mouseout: (e) => e.target.closePopup()
|
||||
}}
|
||||
>
|
||||
<Popup maxWidth={220}>
|
||||
<div className="pu-id">RIDER</div>
|
||||
|
||||
Reference in New Issue
Block a user