changes made in the dispatch file and css files and added the some filters

This commit is contained in:
2026-05-20 19:11:55 +05:30
parent 4cd1b2212d
commit 6d740c196e
2 changed files with 1054 additions and 160 deletions

View File

@@ -90,6 +90,27 @@
opacity: 0.8; 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 { .testing-container .hdr-sep {
width: 1px; width: 1px;
height: 20px; height: 20px;
@@ -114,6 +135,19 @@
border: 1px solid var(--border); 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 */ /* Tabs */
.testing-container #strat-row { .testing-container #strat-row {
height: 48px; height: 48px;
@@ -179,6 +213,88 @@
vertical-align: middle; 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) */ /* Live data controls (date picker + load status) */
.testing-container .live-controls { .testing-container .live-controls {
margin-left: auto; margin-left: auto;
@@ -203,6 +319,13 @@
.testing-container .live-status-ready { color: var(--success); } .testing-container .live-status-ready { color: var(--success); }
.testing-container .live-status-error { color: #ef4444; } .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 { .testing-container .live-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -259,6 +382,42 @@
background: var(--bg-sub); background: var(--bg-sub);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; 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 { .testing-container .batch-label {
@@ -268,6 +427,7 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
margin-right: 4px; margin-right: 4px;
flex-shrink: 0;
} }
.testing-container .batch-btn { .testing-container .batch-btn {
@@ -284,12 +444,14 @@
cursor: pointer; cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit; font-family: inherit;
/* Chips must not shrink — the scroller takes the overflow instead. */
flex-shrink: 0;
white-space: nowrap;
} }
.testing-container .batch-btn:hover { .testing-container .batch-btn:hover {
border-color: var(--text-muted); border-color: var(--text-muted);
color: var(--text); color: var(--text);
transform: translateY(-1px);
} }
.testing-container .batch-btn.active { .testing-container .batch-btn.active {
@@ -298,11 +460,11 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
} }
/* Per-batch active color so each wave reads at a glance */ /* Unified active style for hourly slot chips — single accent gradient instead of
.testing-container .batch-btn.batch-all.active { background: linear-gradient(135deg, #3b82f6, #6366f1); } per-wave colors since 12 different colors would be visually noisy. */
.testing-container .batch-btn.batch-morning.active { background: linear-gradient(135deg, #f59e0b, #f97316); } .testing-container .batch-btn.batch-slot.active {
.testing-container .batch-btn.batch-lunch.active { background: linear-gradient(135deg, #10b981, #14b8a6); } background: linear-gradient(135deg, #3b82f6, #6366f1);
.testing-container .batch-btn.batch-dinner.active { background: linear-gradient(135deg, #6366f1, #8b5cf6); } }
.testing-container .batch-btn-icon { .testing-container .batch-btn-icon {
font-size: 14px; font-size: 14px;
@@ -480,6 +642,178 @@
z-index: 5; 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 { .testing-container #stats-strip {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -825,26 +1159,92 @@
gap: 16px; gap: 16px;
padding-bottom: 20px; padding-bottom: 20px;
position: relative; 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 { .testing-container .step-row:not(:last-child)::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 11px; left: 15px;
top: 24px; top: 32px;
bottom: 0; bottom: 0;
width: 2px; width: 2px;
background: var(--border); background: var(--border);
} }
.testing-container .step-dot { .testing-container .step-dot {
width: 24px; width: 32px;
height: 24px; height: 32px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 14px;
font-weight: 800; font-weight: 800;
z-index: 2; z-index: 2;
flex-shrink: 0; flex-shrink: 0;
@@ -866,6 +1266,19 @@
font-weight: 700; 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 { .testing-container .kitchen-tag {
color: var(--kitchen); color: var(--kitchen);
} }
@@ -889,6 +1302,10 @@
color: var(--success); color: var(--success);
} }
.testing-container .step-profit.is-loss {
color: #dc2626;
}
/* Enriched step row metadata */ /* Enriched step row metadata */
.testing-container .step-location { .testing-container .step-location {
font-size: 11px; font-size: 11px;
@@ -1289,6 +1706,97 @@
background: var(--kitchen); 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 { .testing-container .kitchen-transition {
padding: 12px; padding: 12px;
background: var(--kitchen-soft); background: var(--kitchen-soft);
@@ -1403,29 +1911,46 @@
/* Markers */ /* Markers */
.testing-container .cmark { .testing-container .cmark {
border-radius: 50%; border-radius: 50%;
border: 2px solid #fff; border: 3px solid #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: #fff;
font-weight: 800; font-weight: 800;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
letter-spacing: 0.02em;
} }
.testing-container .kitchen-mark { .testing-container .kitchen-mark {
background: var(--kitchen); background: var(--kitchen);
border: 3px solid #fff; border: 3px solid #fff;
border-radius: 50%; border-radius: 50%;
width: 34px; width: 46px;
height: 34px; height: 46px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: #fff;
font-weight: 900; 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); 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 */ /* Popups - Clean White Look */
.testing-container .leaflet-popup-content-wrapper { .testing-container .leaflet-popup-content-wrapper {
background: #ffffff; background: #ffffff;

View File

@@ -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 { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, ZoomControl } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useInfiniteQuery } from '@tanstack/react-query'; 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 { fetchDeliveries } from '../../api/api';
import './Dispatch.css'; import './Dispatch.css';
import { RAW_DISPATCH_DATA } from './DispatchData'; 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, // 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". // lunch wave, dinner wave). Anything outside a window OR with no parsable time falls under "all".
const BATCHES = [ // Hourly delivery slots: 7am→8am … 6pm→7pm. Slot id is `slot-<startHour>` (24h).
// { id: 'all', label: 'All', icon: '🍽️' }, // hidden for now — restore for an unfiltered view // To shift the window edit BATCH_START_HOUR / BATCH_END_HOUR (end is exclusive).
{ id: 'morning', label: 'Morning', icon: '🌅' }, const BATCH_START_HOUR = 7;
{ id: 'lunch', label: 'Lunch', icon: '🍱' }, const BATCH_END_HOUR = 19;
{ id: 'dinner', label: 'Dinner', icon: '🌙' } 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 getRowBatch = (r) => {
const t = r.expecteddeliverytime || r.deliverydate || r.pickupslot; const t = r.expecteddeliverytime || r.deliverydate || r.pickupslot;
if (!t) return null; if (!t) return null;
const d = dayjs(t); const d = dayjs(t);
if (!d.isValid()) return null; if (!d.isValid()) return null;
const h = d.hour() + d.minute() / 60; const h = d.hour();
if (h < 10.5) return 'morning'; if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return null;
if (h < 15) return 'lunch'; return `slot-${h}`;
return 'dinner';
}; };
const FINAL_STATUSES = new Set(['delivered']); const FINAL_STATUSES = new Set(['delivered']);
@@ -199,6 +226,14 @@ const MapController = ({ focusedItem, viewMode, orders }) => {
return null; 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 = ({ const Dispatch = ({
data, data,
embedded = false, embedded = false,
@@ -218,13 +253,27 @@ const Dispatch = ({
const [internalFocusedRider, setInternalFocusedRider] = useState(null); const [internalFocusedRider, setInternalFocusedRider] = useState(null);
const [focusedKitchen, setFocusedKitchen] = useState(null); const [focusedKitchen, setFocusedKitchen] = useState(null);
const [focusedZone, setFocusedZone] = 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 isControlled = selectedRiderId !== undefined;
const [clock, setClock] = useState(''); const [clock, setClock] = useState('');
const [osrmRoutes, setOsrmRoutes] = useState({}); const [osrmRoutes, setOsrmRoutes] = useState({});
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [animatedSegments, setAnimatedSegments] = useState([]); const [animatedSegments, setAnimatedSegments] = useState([]);
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); 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). // Live deliveries query — runs only when no `data` prop is passed (i.e., standalone page).
const shouldFetchLive = !data; const shouldFetchLive = !data;
@@ -258,10 +307,11 @@ const Dispatch = ({
// Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay // Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay
// visible even when a single batch is active). // visible even when a single batch is active).
const batchCounts = useMemo(() => { 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) => { liveRows.forEach((r) => {
const b = getRowBatch(r); const b = getRowBatch(r);
if (b) counts[b] += 1; if (b) counts[b] = (counts[b] || 0) + 1;
}); });
return counts; return counts;
}, [liveRows]); }, [liveRows]);
@@ -462,6 +512,7 @@ const Dispatch = ({
(r) => { (r) => {
if (onRiderSelect) onRiderSelect(r ? r.id : null); if (onRiderSelect) onRiderSelect(r ? r.id : null);
if (!isControlled) setInternalFocusedRider(r); if (!isControlled) setInternalFocusedRider(r);
setFocusedStop(null);
}, },
[isControlled, onRiderSelect] [isControlled, onRiderSelect]
); );
@@ -499,9 +550,13 @@ const Dispatch = ({
const fetchRoute = useCallback(async (riderId, tripKey, points) => { const fetchRoute = useCallback(async (riderId, tripKey, points) => {
const cacheKey = `${riderId}-${tripKey}`; 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; 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 coords = points.map(p => `${p[1]},${p[0]}`).join(';');
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`; 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]) { if (data.routes && data.routes[0]) {
const poly = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]); const poly = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly })); 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) { } catch (e) {
console.error('OSRM Fetch error:', e); console.error('OSRM Fetch error:', e);
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
} }
}, [osrmRoutes]); }, [osrmRoutes]);
@@ -566,6 +626,60 @@ const Dispatch = ({
}); });
}, [riderPositions, focusedRider, focusedKitchen, activeRiders, fetchRoute]); }, [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 = () => { const startAnimation = () => {
if (isAnimating) { if (isAnimating) {
setIsAnimating(false); setIsAnimating(false);
@@ -630,12 +744,12 @@ const Dispatch = ({
}); });
}; };
const createKitchenIcon = (name) => L.divIcon({ const createKitchenIcon = (name, focused = false) => L.divIcon({
className: '', className: '',
iconSize: [34, 34], iconSize: focused ? [56, 56] : [46, 46],
iconAnchor: [17, 17], iconAnchor: focused ? [28, 28] : [23, 23],
popupAnchor: [0, -18], popupAnchor: [0, focused ? -30 : -24],
html: `<div class="kitchen-mark">${(name || 'K').charAt(0).toUpperCase()}</div>` 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'; const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569';
@@ -644,7 +758,7 @@ const Dispatch = ({
const renderRiderCard = (r, i) => ( const renderRiderCard = (r, i) => (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}> <div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top"> <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-info">
<div className="rcard-name">{r.riderName}</div> <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> <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 className="rcard-badge" style={{ background: `${r.color}18`, color: r.color }}>{r.orders.length}</div>
</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="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"> <div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)} {r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div> </div>
@@ -673,7 +787,8 @@ const Dispatch = ({
// Use the 'step' field from data, fallback to index // Use the 'step' field from data, fallback to index
const seq = o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0); 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 statusStyle = getStatusStyle(o.orderstatus);
const statusLow = String(o.orderstatus || '').toLowerCase(); const statusLow = String(o.orderstatus || '').toLowerCase();
@@ -691,12 +806,25 @@ const Dispatch = ({
className: '', className: '',
iconSize: [sz, sz], iconSize: [sz, sz],
iconAnchor: [sz / 2, sz / 2], iconAnchor: [sz / 2, sz / 2],
popupAnchor: [0, -22], // Lift popup above the flag, not just the marker 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 ? 7 : 8}px;opacity:${active ? 1 : 0.75}">${seq > 0 ? seq : ''}${flagSvg}</div>` 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 ( 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}> <Popup maxWidth={250}>
<div className="pu-id">ORDER #{o.orderid}</div> <div className="pu-id">ORDER #{o.orderid}</div>
<div className="pu-rider" style={{ color }}>{o.rider_name || o.ridername || 'Unassigned'}</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 roadPoints = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Always render the actual road polyline from OSRM — applies to every view // Cache values:
// (riders, zones, all routes, kitchens). If OSRM hasn't responded yet we just // Array → OSRM road polyline (use it)
// don't draw, instead of flashing an aerial line that snaps to the road later. // false → OSRM permanently failed (draw aerial fallback so user sees something)
const finalPoints = roadPoints; // 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; if (!finalPoints || finalPoints.length < 2) return;
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen); const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
const opacity = isActive ? 1.0 : 0.1; const opacity = isActive ? 1.0 : 0.1;
const weight = isKitchenView ? 7 : 6; 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( routes.push(
<React.Fragment key={`${r.id}-${tNum}`}> <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: '#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> </React.Fragment>
); );
}); });
@@ -786,85 +923,148 @@ const Dispatch = ({
<div id="hdr"> <div id="hdr">
<div className="logo"> <div className="logo">
<div className="logo-badge">D</div> <div className="logo-badge">D</div>
<div className="logo-name">Rider <em>Dispatch</em></div> <div className="logo-name">Dispatch</div>
<div className="hdr-sep"></div> <div className="logo-city"><MdPlace /> Coimbatore</div>
<div className="hdr-meta">Coimbatore · {activeStats.orders} orders · {activeStats.riders} {activeStats.riders === 1 ? 'rider' : 'riders'} ({activeStats.label})</div>
</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 id="clock">{clock}</div>
</div> </div>
)} )}
<div id="strat-row"> <div id="strat-row">
{zoneCards.length > 0 && ( <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 <button
className={`sbt ${viewMode === 'zones' ? 'active' : ''}`} className={`sbt ${viewMode === 'zones' ? 'active' : ''}`}
onClick={() => { setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }} onClick={() => { setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
><span className="sbt-icon"><MdMap /></span> By Zone</button> ><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 === '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> <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> </div>
{shouldFetchLive && ( {shouldFetchLive && (
<div id="batch-row"> <div id="batch-row">
<span className="batch-label">Batch</span> <span className="batch-label">Slot</span>
{BATCHES.map((b) => ( {/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
<button horizontally when it overflows. */}
key={b.id} <div className="batch-scroll">
className={`batch-btn batch-${b.id} ${selectedBatch === b.id ? 'active' : ''}`} {BATCHES.map((b) => {
onClick={() => { const isActive = selectedBatch === b.id;
setSelectedBatch(b.id); return (
handleRiderFocus(null); <button
setFocusedKitchen(null); key={b.id}
setFocusedZone(null); ref={isActive ? activeBatchRef : null}
}} className={`batch-btn batch-slot ${isActive ? 'active' : ''}`}
title={`${b.label} batch`} onClick={() => {
> setSelectedBatch(b.id);
<span className="batch-btn-icon">{b.icon}</span> handleRiderFocus(null);
<span className="batch-btn-label">{b.label}</span> setFocusedKitchen(null);
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span> setFocusedZone(null);
</button> }}
))} 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>
)} )}
<div id="body"> <div id="body">
<div id="sidebar"> <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. {/* Stats strip hidden for now — restore by removing this comment wrapper.
<div id="stats-strip"> <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> <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 ( return (
<div className="rd-stats-grid"> <div className="rd-stats-grid">
<div className="rd-stat rd-stat-orders"> <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-value">{focusedRider.orders.length}</div>
<div className="rd-stat-label">Orders</div> <div className="rd-stat-label">Orders</div>
</div> </div>
<div className="rd-stat rd-stat-distance"> <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-value">{totalKm.toFixed(1)}<span className="rd-stat-unit">km</span></div>
<div className="rd-stat-label">Distance</div> <div className="rd-stat-label">Distance</div>
</div> </div>
<div className={`rd-stat rd-stat-profit ${isLoss ? 'is-loss' : 'is-gain'}`}> <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"> <div className="rd-stat-value">
{isLoss ? '-' : ''}{Math.abs(totalProfit).toFixed(0)} {isLoss ? '-' : ''}{Math.abs(totalProfit).toFixed(0)}
</div> </div>
@@ -913,6 +1113,20 @@ const Dispatch = ({
if (!trips[t]) trips[t] = []; if (!trips[t]) trips[t] = [];
trips[t].push(o); 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; let prevKitchenKey = null;
return Object.entries(trips) return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b)) .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` }}> <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="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
<span className="trip-stats"> <span className="trip-stats">
<span>📍 {tOrders.length} stops</span> <span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span>📏 {tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span> <span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span> </span>
</div> </div>
<div className="step-wrap"> <div className="step-wrap">
@@ -933,22 +1147,41 @@ const Dispatch = ({
return ( return (
<React.Fragment key={o.orderid}> <React.Fragment key={o.orderid}>
{showTransition && ( {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 && ( {idx === 0 && (
<div className="step-row"> <div className="step-row">
<div className="step-col-left"><div className="step-dot kitchen">K</div></div> <div className="step-col-left"><div className="step-dot kitchen">K</div></div>
<div className="step-col-body"> <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 className="step-dest">Pickup point · Trip {tNum}</div>
</div> </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-left"><div className="step-dot delivery">{o.step || idx + 1}</div></div>
<div className="step-col-body"> <div className="step-col-body">
<div className="step-label" style={{ display: 'flex', alignItems: 'center' }}> <div className="step-label step-label-row">
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>📬 {o.deliverycustomer}</span> <span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
{o.orderstatus && (() => { {o.orderstatus && (() => {
const s = getStatusStyle(o.orderstatus); const s = getStatusStyle(o.orderstatus);
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered'; const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
@@ -967,15 +1200,28 @@ const Dispatch = ({
})()} })()}
</div> </div>
<div className="step-dest">Order #{o.orderid}</div> <div className="step-dest">Order #{o.orderid}</div>
{(o.locationname || o.locationsuburb) && ( {/* Show the customer's delivery address rather than the kitchen's
<div className="step-location">📍 {[o.locationname, o.locationsuburb].filter(Boolean).join(' · ')}</div> 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 && ( {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"> <div className="step-detail">
<span>📏 {o.actualkms || o.kms || 0} km</span> <span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
<span className="step-profit">💰 {parseFloat(o.profit || 0).toFixed(0)}</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 && ( {o.deliverycharge != null && (
<span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span> <span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
)} )}
@@ -985,6 +1231,8 @@ const Dispatch = ({
</div> </div>
</div> </div>
</div> </div>
);
})()}
</React.Fragment> </React.Fragment>
); );
})} })}
@@ -997,16 +1245,34 @@ const Dispatch = ({
<> <>
<div className="rd-rider-name" style={{ color: '#f59e0b' }}>{focusedKitchen.kitchenName}</div> <div className="rd-rider-name" style={{ color: '#f59e0b' }}>{focusedKitchen.kitchenName}</div>
<div className="rd-rider-sub"> <div className="rd-rider-sub">
<span>📦 {focusedKitchen.orders.length} orders</span> <span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
<span>🏍 {focusedKitchen.riders.size} riders</span> <span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
</div> </div>
<div className="step-wrap"> <div className="step-wrap">
{focusedKitchen.orders.map((o, idx) => ( {focusedKitchen.orders.map((o, idx) => {
<div key={o.orderid} className="step-row"> const lat = parseFloat(o.droplat || o.deliverylat);
<div className="step-col-left"><div className="step-dot delivery" style={{ background: getRiderColor(o.rider_id) }}>{idx + 1}</div></div> 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-col-body">
<div className="step-label" style={{ display: 'flex', alignItems: 'center' }}> <div className="step-label step-label-row">
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>📬 {o.deliverycustomer}</span> <span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
{o.orderstatus && (() => { {o.orderstatus && (() => {
const s = getStatusStyle(o.orderstatus); const s = getStatusStyle(o.orderstatus);
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered'; const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
@@ -1025,15 +1291,29 @@ const Dispatch = ({
})()} })()}
</div> </div>
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div> <div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div>
{(o.locationname || o.locationsuburb) && ( {/* In the By-Kitchen view we show the customer's delivery address,
<div className="step-location">📍 {[o.locationname, o.locationsuburb].filter(Boolean).join(' · ')}</div> 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 && ( {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"> <div className="step-detail">
<span>📏 {o.actualkms || o.kms || 0} km</span> <span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
<span className="step-profit">💰 {parseFloat(o.profit || 0).toFixed(0)}</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 && ( {o.deliverycharge != null && (
<span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span> <span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
)} )}
@@ -1043,7 +1323,8 @@ const Dispatch = ({
</div> </div>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</> </>
)} )}
@@ -1053,10 +1334,12 @@ const Dispatch = ({
<button className="rd-back" onClick={() => setFocusedZone(null)}> Back to zones</button> <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-name" style={{ color: '#3b82f6' }}>{focusedZone.name}</div>
<div className="rd-rider-sub"> <div className="rd-rider-sub">
<span>📦 {focusedZone.totalOrders} orders</span> <span><Ico><MdInventory2 /></Ico>{focusedZone.totalOrders} orders</span>
<span>🏍 {focusedZone.activeRidersCount} riders</span> <span><Ico><MdTwoWheeler /></Ico>{focusedZone.activeRidersCount} riders</span>
<span>📏 {focusedZone.totalKms.toFixed(1)} km</span> <span><Ico><MdStraighten /></Ico>{focusedZone.totalKms.toFixed(1)} km</span>
<span className="step-profit">💰 {focusedZone.totalProfit.toFixed(0)}</span> <span className={`step-profit ${focusedZone.totalProfit < 0 ? 'is-loss' : ''}`}>
<Ico><MdAccountBalanceWallet /></Ico>{focusedZone.totalProfit < 0 ? '-' : ''}{Math.abs(focusedZone.totalProfit).toFixed(0)}
</span>
</div> </div>
{/* Status breakdown */} {/* Status breakdown */}
@@ -1093,18 +1376,100 @@ const Dispatch = ({
</div> </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 && ( {focusedZone.suburbs.length > 0 && (
<div className="zone-detail-section"> <div className="zone-detail-section">
<div className="zone-section-label">Areas Covered <span className="section-count">({focusedZone.suburbs.length})</span></div> <div className="zone-section-label">Areas Covered <span className="section-count">({focusedZone.suburbs.length})</span></div>
<div className="zone-chips"> <div className="zone-chips">
{focusedZone.suburbs.map((s) => ( {focusedZone.suburbs.map((s) => {
<span key={s.name} className="zone-chip"> const isActive = selectedSuburb === s.name;
<span className="zone-chip-name">{s.name}</span> return (
<span className="zone-chip-count">{s.count}</span> <button
</span> 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> </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> </div>
)} )}
@@ -1115,7 +1480,7 @@ const Dispatch = ({
<div className="zone-chips"> <div className="zone-chips">
{focusedZone.kitchens.map((k) => ( {focusedZone.kitchens.map((k) => (
<span key={k.name} className="zone-chip kitchen"> <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 className="zone-chip-count kitchen">{k.count}</span>
</span> </span>
))} ))}
@@ -1147,7 +1512,7 @@ const Dispatch = ({
return ( return (
<div key={z.id} className="rcard zone-card" onClick={() => setFocusedZone(z)} style={{ animationDelay: `${i * 0.05}s` }}> <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-header">
<div className="zone-card-emoji">🗺</div> <div className="zone-card-emoji"><MdMap /></div>
<div className="zone-card-titles"> <div className="zone-card-titles">
<div className="zone-card-name">{z.name}</div> <div className="zone-card-name">{z.name}</div>
<div className="zone-card-sub"> <div className="zone-card-sub">
@@ -1187,22 +1552,22 @@ const Dispatch = ({
{/* Stat pills */} {/* Stat pills */}
<div className="zone-stat-pills"> <div className="zone-stat-pills">
<span className="zone-stat-pill" title="Areas covered"> <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-value">{z.suburbs.length}</span>
<span className="zone-stat-label">{z.suburbs.length === 1 ? 'area' : 'areas'}</span> <span className="zone-stat-label">{z.suburbs.length === 1 ? 'area' : 'areas'}</span>
</span> </span>
<span className="zone-stat-pill" title="Total distance"> <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-value">{z.totalKms.toFixed(1)}</span>
<span className="zone-stat-label">km</span> <span className="zone-stat-label">km</span>
</span> </span>
<span className="zone-stat-pill" title="Kitchens"> <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-value">{z.kitchens.length}</span>
<span className="zone-stat-label">{z.kitchens.length === 1 ? 'kitchen' : 'kitchens'}</span> <span className="zone-stat-label">{z.kitchens.length === 1 ? 'kitchen' : 'kitchens'}</span>
</span> </span>
<span className={`zone-stat-pill ${profitNeg ? 'profit-negative' : 'profit-positive'}`} title="Total profit"> <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"> <span className="zone-stat-value">
{profitNeg ? `-₹${Math.abs(z.totalProfit).toFixed(0)}` : `${z.totalProfit.toFixed(0)}`} {profitNeg ? `-₹${Math.abs(z.totalProfit).toFixed(0)}` : `${z.totalProfit.toFixed(0)}`}
</span> </span>
@@ -1226,15 +1591,15 @@ const Dispatch = ({
kitchens.map((k, i) => ( kitchens.map((k, i) => (
<div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}> <div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top"> <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-info">
<div className="rcard-name">{k.kitchenName}</div> <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>
<div className="rcard-badge" style={{ background: '#f59e0b18', color: '#f59e0b' }}>{k.orders.length}</div> <div className="rcard-badge" style={{ background: '#f59e0b18', color: '#f59e0b' }}>{k.orders.length}</div>
</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="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"> <div className="step-ids">
{Array.from(k.riders).slice(0, 10).map(rid => ( {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> <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}> <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='&copy; OpenStreetMap contributors' /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" /> <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 {kitchens
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.filter(k => !focusedRider || k.riders.has(focusedRider.id)) .filter(k => !focusedRider || k.riders.has(focusedRider.id))
@@ -1265,8 +1630,8 @@ const Dispatch = ({
<Marker <Marker
key={`k-${i}`} key={`k-${i}`}
position={[k.lat, k.lon]} position={[k.lat, k.lon]}
icon={createKitchenIcon(k.kitchenName)} icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id)}
zIndexOffset={2000} zIndexOffset={focusedKitchen?.id === k.id ? 4000 : 2000}
eventHandlers={{ eventHandlers={{
click: () => setFocusedKitchen(k), click: () => setFocusedKitchen(k),
mouseover: (e) => e.target.openPopup(), mouseover: (e) => e.target.openPopup(),
@@ -1312,7 +1677,11 @@ const Dispatch = ({
position={pos} position={pos}
icon={bikeIcon} icon={bikeIcon}
zIndexOffset={3000} 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}> <Popup maxWidth={220}>
<div className="pu-id">RIDER</div> <div className="pu-id">RIDER</div>