update on the pickup and edit slot in the dispatch page
This commit is contained in:
@@ -544,6 +544,330 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Slot-time-field dropdown — picks which timestamp column drives slot
|
||||||
|
bucketing. Styled to match the location-pill dropdown in the header so
|
||||||
|
both feel like the same kind of filter control. */
|
||||||
|
.testing-container .time-field-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(123, 31, 162, 0.08);
|
||||||
|
border: 1px solid rgba(123, 31, 162, 0.25);
|
||||||
|
color: #7b1fa2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-btn:hover {
|
||||||
|
background: rgba(123, 31, 162, 0.14);
|
||||||
|
border-color: rgba(123, 31, 162, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-btn.open {
|
||||||
|
background: rgba(123, 31, 162, 0.18);
|
||||||
|
border-color: rgba(123, 31, 162, 0.55);
|
||||||
|
box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-btn svg {
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-caret {
|
||||||
|
font-size: 15px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-btn.open .time-field-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(123, 31, 162, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
|
||||||
|
padding: 6px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: logo-city-menu-in 0.14s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-option:hover {
|
||||||
|
background: rgba(123, 31, 162, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-option.active {
|
||||||
|
background: rgba(123, 31, 162, 0.1);
|
||||||
|
color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-option-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #7b1fa2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .time-field-option-check {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #7b1fa2;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slot timings editor — popover anchored to a small "Edit slots" button in
|
||||||
|
the batch row. Lets the operator tweak start/end hours, add new slots,
|
||||||
|
delete existing ones, or reset to the default 5-slot layout. */
|
||||||
|
.testing-container .slot-edit-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
border: 1px dashed rgba(15, 23, 42, 0.18);
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-btn:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
border-color: rgba(15, 23, 42, 0.32);
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-btn.open {
|
||||||
|
background: rgba(123, 31, 162, 0.1);
|
||||||
|
border-color: rgba(123, 31, 162, 0.5);
|
||||||
|
border-style: solid;
|
||||||
|
color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-btn svg {
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 340px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(123, 31, 162, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.2);
|
||||||
|
padding: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: logo-city-menu-in 0.14s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-head {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 70px 70px 1fr 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-idx {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(123, 31, 162, 0.12);
|
||||||
|
color: #7b1fa2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-field-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-field input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #7b1fa2;
|
||||||
|
box-shadow: 0 0 0 3px rgba(123, 31, 162, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-preview {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-remove {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.32);
|
||||||
|
background: rgba(220, 38, 38, 0.06);
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-remove:hover:not(:disabled) {
|
||||||
|
background: rgba(220, 38, 38, 0.14);
|
||||||
|
border-color: rgba(220, 38, 38, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-remove:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-add,
|
||||||
|
.testing-container .slot-edit-reset {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-add {
|
||||||
|
background: #7b1fa2;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-add:hover {
|
||||||
|
background: #6a1591;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-reset {
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
border-color: rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .slot-edit-reset:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.testing-container .batch-btn {
|
.testing-container .batch-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -738,6 +1062,60 @@
|
|||||||
50% { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(255, 255, 255, 0.15); }
|
50% { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(255, 255, 255, 0.15); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Live rider pin (from /partners/getriderlogs/) — colored teardrop with a
|
||||||
|
floating label showing the rider's username + current order. Status drives
|
||||||
|
the color: green for active, red otherwise. Lives next to the synthetic
|
||||||
|
bike markers but uses a distinct visual so the operator can tell that this
|
||||||
|
one is real-GPS, not route-progress estimate. */
|
||||||
|
.testing-container .live-rider-pin {
|
||||||
|
--pin-color: #16a34a;
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 41px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .live-rider-pin-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--pin-color);
|
||||||
|
border: 3px solid #fff;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .live-rider-pin-marker::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .live-rider-pin-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 30px;
|
||||||
|
top: 2px;
|
||||||
|
background: var(--pin-color);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .live-rider-pin-label span {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Body layout */
|
/* Body layout */
|
||||||
.testing-container #body {
|
.testing-container #body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -2136,15 +2514,24 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
display: -webkit-box;
|
/* Force a single-line, ellipsised row — long unstructured addresses used to
|
||||||
-webkit-line-clamp: 2;
|
wrap to 2-3 lines and made cards look noisy. Full address still surfaces
|
||||||
-webkit-box-orient: vertical;
|
on hover via the `title` attribute. */
|
||||||
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.testing-container .zone-order-notes {
|
.testing-container .zone-order-notes {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
|
/* Notes can be longer; let them breathe over 2 lines and override the
|
||||||
|
single-line ellipsis applied to .zone-order-line above. */
|
||||||
|
white-space: normal;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer stat chips */
|
/* Footer stat chips */
|
||||||
@@ -3557,6 +3944,27 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Permanent banner sitting above the rider's GPS pin in the Rider Info map.
|
||||||
|
Shows the suburb/area name reverse-geocoded from lat/lon so the operator
|
||||||
|
can read the location without opening the popup. Styled to override the
|
||||||
|
default leaflet tooltip chrome (rounded chip, brand purple). */
|
||||||
|
.testing-container .ri-map .leaflet-tooltip.ri-area-banner {
|
||||||
|
background: #7b1fa2;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .ri-map .leaflet-tooltip.ri-area-banner::before {
|
||||||
|
border-top-color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile — collapse the sidebar above the main panel, single-column stats */
|
/* Mobile — collapse the sidebar above the main panel, single-column stats */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.testing-container .rider-info-mode {
|
.testing-container .rider-info-mode {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback, useRef } 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, Tooltip, 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';
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
MdPower,
|
MdPower,
|
||||||
MdSearch
|
MdSearch
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs } from '../../api/api';
|
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api';
|
||||||
import './Dispatch.css';
|
import './Dispatch.css';
|
||||||
|
|
||||||
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes.
|
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes.
|
||||||
@@ -59,6 +59,29 @@ const toNum = (v) => {
|
|||||||
return Number.isFinite(n) ? n : NaN;
|
return Number.isFinite(n) ? n : NaN;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Long delivery addresses come in two shapes:
|
||||||
|
// 1. Comma-separated: "Room No:C-4, Second Floor, ..., Vetrilaikara St,
|
||||||
|
// Peelamedu" — we keep the last two segments (typically street + area).
|
||||||
|
// 2. Free-form / space-separated: "Vistara Homes 71 & 72 ... Uppilipalayam
|
||||||
|
// post Coimbatore - 641 015 Opposite ..." — Indian addresses often run
|
||||||
|
// everything into one comma-less string. There's no reliable way to
|
||||||
|
// pick the locality token, so we hard-cap to the last 6 words and trim
|
||||||
|
// to ~40 chars; the full address still lives in the row's title tooltip.
|
||||||
|
const extractArea = (addr) => {
|
||||||
|
if (!addr) return '';
|
||||||
|
const str = String(addr).trim();
|
||||||
|
if (!str) return '';
|
||||||
|
if (str.includes(',')) {
|
||||||
|
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (parts.length === 0) return str;
|
||||||
|
if (parts.length <= 2) return parts.join(', ');
|
||||||
|
return parts.slice(-2).join(', ');
|
||||||
|
}
|
||||||
|
const words = str.split(/\s+/).filter(Boolean);
|
||||||
|
const tail = words.length > 6 ? words.slice(-6).join(' ') : str;
|
||||||
|
return tail.length > 40 ? `${tail.slice(0, 40).trim()}…` : tail;
|
||||||
|
};
|
||||||
|
|
||||||
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
|
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
|
||||||
// Try multiple field-name variants — the live delivery API may return pickuplatitude/picklongitude
|
// Try multiple field-name variants — the live delivery API may return pickuplatitude/picklongitude
|
||||||
// or pickuplongitude instead of the shorter pickuplat/pickuplong used in the static data.
|
// or pickuplongitude instead of the shorter pickuplat/pickuplong used in the static data.
|
||||||
@@ -71,7 +94,11 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF
|
|||||||
// fall outside every slot (e.g. 11 AM, the gap between Slot 1 and Slot 2)
|
// fall outside every slot (e.g. 11 AM, the gap between Slot 1 and Slot 2)
|
||||||
// produce a null batch and the order won't appear in any chip.
|
// produce a null batch and the order won't appear in any chip.
|
||||||
// Slot 5 ends at 24 so anything from 8 PM until midnight buckets there.
|
// Slot 5 ends at 24 so anything from 8 PM until midnight buckets there.
|
||||||
const BATCHES = [
|
// Default slot layout. Used as the seed for the editable slot config the
|
||||||
|
// operator can tweak at runtime — see slotsConfig state + the slot-edit
|
||||||
|
// popover below. Don't read BATCHES_DEFAULT directly at runtime; read
|
||||||
|
// component state instead so user edits take effect.
|
||||||
|
const BATCHES_DEFAULT = [
|
||||||
{ id: 'slot-1', label: 'Slot 1 · 8 AM', range: '8–11 AM', startHour: 8, endHour: 11 },
|
{ id: 'slot-1', label: 'Slot 1 · 8 AM', range: '8–11 AM', startHour: 8, endHour: 11 },
|
||||||
{ id: 'slot-2', label: 'Slot 2 · 12 PM', range: '12–3 PM', startHour: 12, endHour: 15 },
|
{ id: 'slot-2', label: 'Slot 2 · 12 PM', range: '12–3 PM', startHour: 12, endHour: 15 },
|
||||||
{ id: 'slot-3', label: 'Slot 3 · 3 PM', range: '3–7 PM', startHour: 15, endHour: 19 },
|
{ id: 'slot-3', label: 'Slot 3 · 3 PM', range: '3–7 PM', startHour: 15, endHour: 19 },
|
||||||
@@ -79,25 +106,65 @@ const BATCHES = [
|
|||||||
{ id: 'slot-5', label: 'Slot 5 · 8 PM', range: 'After 8 PM', startHour: 20, endHour: 24 }
|
{ id: 'slot-5', label: 'Slot 5 · 8 PM', range: 'After 8 PM', startHour: 20, endHour: 24 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const getBatchForHour = (h) => {
|
const SLOTS_STORAGE_KEY = 'dispatch.slots.v1';
|
||||||
for (const b of BATCHES) {
|
|
||||||
|
// Build a label like "Slot 1 · 8 AM" from a startHour (24h). Mirrors the
|
||||||
|
// human-readable form the defaults use, so user-edited slots still look
|
||||||
|
// consistent in the UI.
|
||||||
|
const formatSlotLabel = (idx, startHour) => {
|
||||||
|
const h = ((startHour + 11) % 12) + 1;
|
||||||
|
const ampm = startHour >= 12 && startHour < 24 ? 'PM' : 'AM';
|
||||||
|
return `Slot ${idx + 1} · ${h} ${ampm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHourLabel = (h) => {
|
||||||
|
const hr = ((h + 11) % 12) + 1;
|
||||||
|
const ampm = h >= 12 && h < 24 ? 'PM' : 'AM';
|
||||||
|
return `${hr} ${ampm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSlotRange = (startHour, endHour) => {
|
||||||
|
if (endHour >= 24) return `After ${formatHourLabel(startHour)}`;
|
||||||
|
return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBatchForHour = (h, batches) => {
|
||||||
|
for (const b of batches) {
|
||||||
if (h >= b.startHour && h < b.endHour) return b.id;
|
if (h >= b.startHour && h < b.endHour) return b.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRowBatch = (r) => {
|
// Time fields the operator can pick from to drive slot bucketing. Each
|
||||||
// Filter by actual delivery time first; fall back to expected delivery time only
|
// option maps to a column on the delivery row; the chosen one becomes the
|
||||||
// when the order hasn't been completed yet. Both are real delivery-clock fields —
|
// timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime
|
||||||
// not arrival/assign/pickup timestamps, which led to mis-bucketing earlier.
|
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
|
||||||
const t = r.deliverytime || r.expecteddeliverytime;
|
const TIME_FIELDS = [
|
||||||
|
{ id: 'delivery', label: 'Delivery', keys: ['deliverytime', 'expecteddeliverytime'] },
|
||||||
|
{ id: 'assigned', label: 'Assigned', keys: ['assigntime'] },
|
||||||
|
{ id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
|
||||||
|
{ id: 'started', label: 'Started', keys: ['starttime'] },
|
||||||
|
{ id: 'arrived', label: 'Arrived', keys: ['arrivaltime'] },
|
||||||
|
{ id: 'pickup', label: 'Pickup', keys: ['pickuptime'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const getTimeFieldValue = (r, fieldId) => {
|
||||||
|
const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0];
|
||||||
|
for (const k of field.keys) {
|
||||||
|
if (r?.[k]) return r[k];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowBatch = (r, fieldId = 'delivery', batches = BATCHES_DEFAULT) => {
|
||||||
|
const t = getTimeFieldValue(r, fieldId);
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
const str = String(t).trim();
|
const str = String(t).trim();
|
||||||
// Skip bare date strings — no time component, would always parse to midnight.
|
// Skip bare date strings — no time component, would always parse to midnight.
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||||
const d = dayjs(t);
|
const d = dayjs(t);
|
||||||
if (!d.isValid()) return null;
|
if (!d.isValid()) return null;
|
||||||
return getBatchForHour(d.hour());
|
return getBatchForHour(d.hour(), batches);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FINAL_STATUSES = new Set(['delivered']);
|
const FINAL_STATUSES = new Set(['delivered']);
|
||||||
@@ -334,6 +401,56 @@ const Dispatch = ({
|
|||||||
const [locationMenuOpen, setLocationMenuOpen] = useState(false);
|
const [locationMenuOpen, setLocationMenuOpen] = useState(false);
|
||||||
const locationMenuRef = useRef(null);
|
const locationMenuRef = useRef(null);
|
||||||
|
|
||||||
|
// Which timestamp column drives slot bucketing. Default = delivery time
|
||||||
|
// (operator's primary mental model — "did this order land in the X-Y wave?").
|
||||||
|
// Switching to Assigned/Accepted/Arrived/Pickup/Started rebuckets every row
|
||||||
|
// through `getRowBatch(_, selectedTimeField)`.
|
||||||
|
const [selectedTimeField, setSelectedTimeField] = useState('delivery');
|
||||||
|
const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false);
|
||||||
|
const timeFieldMenuRef = useRef(null);
|
||||||
|
|
||||||
|
// Operator-editable slot configuration. Seeded from localStorage so edits
|
||||||
|
// survive reloads; falls back to BATCHES_DEFAULT otherwise. Each entry has
|
||||||
|
// id/label/range/startHour/endHour just like the default list — the rest
|
||||||
|
// of the file reads BATCHES (derived below) without caring whether the
|
||||||
|
// values came from defaults or from operator edits.
|
||||||
|
const [slotsConfig, setSlotsConfig] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return BATCHES_DEFAULT;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
|
||||||
|
if (!raw) return BATCHES_DEFAULT;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) return BATCHES_DEFAULT;
|
||||||
|
// Re-derive label + range from the saved hours so any UI tweaks to the
|
||||||
|
// formatter (e.g. AM/PM style) flow through to old persisted slots.
|
||||||
|
return parsed.map((s, i) => ({
|
||||||
|
id: s.id || `slot-${i + 1}`,
|
||||||
|
startHour: Number(s.startHour) || 0,
|
||||||
|
endHour: Number(s.endHour) || 24,
|
||||||
|
label: formatSlotLabel(i, Number(s.startHour) || 0),
|
||||||
|
range: formatSlotRange(Number(s.startHour) || 0, Number(s.endHour) || 24)
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
return BATCHES_DEFAULT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const BATCHES = slotsConfig;
|
||||||
|
const [slotEditOpen, setSlotEditOpen] = useState(false);
|
||||||
|
const slotEditRef = useRef(null);
|
||||||
|
|
||||||
|
// Persist edits whenever slotsConfig changes (skip the first render — the
|
||||||
|
// initializer already loaded from storage).
|
||||||
|
const slotsInitMountedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slotsInitMountedRef.current) { slotsInitMountedRef.current = true; return; }
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify(
|
||||||
|
slotsConfig.map(({ id, startHour, endHour }) => ({ id, startHour, endHour }))
|
||||||
|
));
|
||||||
|
} catch (e) { /* quota / private-mode — ignore */ }
|
||||||
|
}, [slotsConfig]);
|
||||||
|
|
||||||
// Close the location dropdown on any click outside its wrapper.
|
// Close the location dropdown on any click outside its wrapper.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!locationMenuOpen) return;
|
if (!locationMenuOpen) return;
|
||||||
@@ -346,6 +463,30 @@ const Dispatch = ({
|
|||||||
return () => document.removeEventListener('mousedown', onDocClick);
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
}, [locationMenuOpen]);
|
}, [locationMenuOpen]);
|
||||||
|
|
||||||
|
// Same click-outside behavior for the slot-time-field dropdown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timeFieldMenuOpen) return;
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (timeFieldMenuRef.current && !timeFieldMenuRef.current.contains(e.target)) {
|
||||||
|
setTimeFieldMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
|
}, [timeFieldMenuOpen]);
|
||||||
|
|
||||||
|
// Click-outside dismissal for the slot-edit popover.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slotEditOpen) return;
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (slotEditRef.current && !slotEditRef.current.contains(e.target)) {
|
||||||
|
setSlotEditOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
|
}, [slotEditOpen]);
|
||||||
|
|
||||||
// Rider Info view — operator picks a rider in the sidebar, the main panel
|
// Rider Info view — operator picks a rider in the sidebar, the main panel
|
||||||
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
|
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
|
||||||
// ('rider-info') so it follows the same toggle pattern as the other modes.
|
// ('rider-info') so it follows the same toggle pattern as the other modes.
|
||||||
@@ -369,6 +510,41 @@ const Dispatch = ({
|
|||||||
refetchOnWindowFocus: false
|
refetchOnWindowFocus: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reverse-geocode the selected rider's GPS so we can show a small banner
|
||||||
|
// above the map pin telling the operator which suburb/area the rider is in.
|
||||||
|
// Nominatim is rate-limited (1 req/sec public), so we round coords to 4
|
||||||
|
// decimals (~11 m) — that turns the query key into a stable cache slot and
|
||||||
|
// stops jittery GPS fixes from re-firing the request every poll cycle.
|
||||||
|
const riderInfoCoordsKey = useMemo(() => {
|
||||||
|
const lat = parseFloat(riderInfoData?.latitude);
|
||||||
|
const lon = parseFloat(riderInfoData?.longitude);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
return { lat: lat.toFixed(4), lon: lon.toFixed(4) };
|
||||||
|
}, [riderInfoData?.latitude, riderInfoData?.longitude]);
|
||||||
|
|
||||||
|
const { data: riderInfoArea } = useQuery({
|
||||||
|
queryKey: ['reverseGeocode', riderInfoCoordsKey?.lat, riderInfoCoordsKey?.lon],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${riderInfoCoordsKey.lat}&lon=${riderInfoCoordsKey.lon}&format=json&zoom=16&addressdetails=1`,
|
||||||
|
{ headers: { Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const j = await res.json();
|
||||||
|
const a = j?.address || {};
|
||||||
|
// Prefer the most specific locality name available; the bigger admin
|
||||||
|
// levels (city/county/state) are kept only as last-resort fallbacks.
|
||||||
|
const area =
|
||||||
|
a.suburb || a.neighbourhood || a.village || a.hamlet ||
|
||||||
|
a.city_district || a.town || a.city || a.county || a.state || '';
|
||||||
|
return { area, display: j?.display_name || '' };
|
||||||
|
},
|
||||||
|
enabled: viewMode === 'rider-info' && !!riderInfoCoordsKey,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1
|
||||||
|
});
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!appLocations) return null;
|
if (!appLocations) return null;
|
||||||
const match = appLocations.find((l) => String(l.applocationid) === String(selectedAppLocationId));
|
const match = appLocations.find((l) => String(l.applocationid) === String(selectedAppLocationId));
|
||||||
@@ -398,11 +574,59 @@ const Dispatch = ({
|
|||||||
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'));
|
||||||
|
|
||||||
|
// Pull the partners/getriderlogs feed for the currently selected hub + date.
|
||||||
|
// This endpoint returns the exact live GPS position for every rider at the
|
||||||
|
// hub (latitude/longitude/logdate/status). We render those positions as
|
||||||
|
// markers on the main dispatch map so the operator sees where each rider
|
||||||
|
// actually is — matching the Reports → Riders Logs page. The synthetic
|
||||||
|
// bike markers driven by riderPositions are route-progress estimates, not
|
||||||
|
// real GPS, so they stay separate.
|
||||||
|
const { data: ridersLocationLogs } = useQuery({
|
||||||
|
queryKey: [selectedAppLocationId, selectedDate, ''],
|
||||||
|
queryFn: fetchRidersLogs,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
staleTime: 15 * 1000,
|
||||||
|
refetchOnWindowFocus: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize the feed into map-ready rider points. Drop entries without a
|
||||||
|
// usable lat/lon — those would crash the Leaflet Marker.
|
||||||
|
const liveRiderLocations = useMemo(() => {
|
||||||
|
return (ridersLocationLogs || [])
|
||||||
|
.map((r) => {
|
||||||
|
const lat = parseFloat(r?.latitude);
|
||||||
|
const lon = parseFloat(r?.longitude);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
return {
|
||||||
|
id: String(r.userid ?? ''),
|
||||||
|
userid: r.userid,
|
||||||
|
username: r.username || `Rider #${r.userid}`,
|
||||||
|
status: String(r.status || '').toLowerCase(),
|
||||||
|
contactno: r.contactno,
|
||||||
|
orderid: r.orderid,
|
||||||
|
logdate: r.logdate,
|
||||||
|
lat,
|
||||||
|
lon
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [ridersLocationLogs]);
|
||||||
// Default to the slot containing the current hour; if we're outside every slot
|
// Default to the slot containing the current hour; if we're outside every slot
|
||||||
// window (e.g. before 8 AM or in the 11–12 gap) fall back to the first slot.
|
// window (e.g. before 8 AM or in the 11–12 gap) fall back to the first slot.
|
||||||
const [selectedBatch, setSelectedBatch] = useState(() => {
|
const [selectedBatch, setSelectedBatch] = useState(() => {
|
||||||
return getBatchForHour(dayjs().hour()) || BATCHES[0].id;
|
return getBatchForHour(dayjs().hour(), BATCHES_DEFAULT) || BATCHES_DEFAULT[0].id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the operator deletes the slot currently selected, fall back to the
|
||||||
|
// first remaining slot so the page doesn't show an empty bucket.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBatch === 'all') return;
|
||||||
|
if (!BATCHES.some((b) => b.id === selectedBatch)) {
|
||||||
|
setSelectedBatch(BATCHES[0]?.id || 'all');
|
||||||
|
}
|
||||||
|
}, [BATCHES, selectedBatch]);
|
||||||
const activeBatchRef = useRef(null);
|
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).
|
||||||
@@ -467,22 +691,23 @@ const Dispatch = ({
|
|||||||
}, [liveRows]);
|
}, [liveRows]);
|
||||||
|
|
||||||
// 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). Recomputes whenever the operator
|
||||||
|
// picks a different timestamp column to bucket on, or edits the slot ranges.
|
||||||
const batchCounts = useMemo(() => {
|
const batchCounts = useMemo(() => {
|
||||||
const counts = { all: liveRows.length };
|
const counts = { all: liveRows.length };
|
||||||
BATCHES.forEach((b) => { counts[b.id] = 0; });
|
BATCHES.forEach((b) => { counts[b.id] = 0; });
|
||||||
liveRows.forEach((r) => {
|
liveRows.forEach((r) => {
|
||||||
const b = getRowBatch(r);
|
const b = getRowBatch(r, selectedTimeField, BATCHES);
|
||||||
if (b) counts[b] = (counts[b] || 0) + 1;
|
if (b) counts[b] = (counts[b] || 0) + 1;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
}, [liveRows]);
|
}, [liveRows, selectedTimeField, BATCHES]);
|
||||||
|
|
||||||
// Apply the batch filter before grouping so zones/riders/bikes all reflect the chosen wave.
|
// Apply the batch filter before grouping so zones/riders/bikes all reflect the chosen wave.
|
||||||
const filteredLiveRows = useMemo(() => {
|
const filteredLiveRows = useMemo(() => {
|
||||||
if (selectedBatch === 'all') return liveRows;
|
if (selectedBatch === 'all') return liveRows;
|
||||||
return liveRows.filter((r) => getRowBatch(r) === selectedBatch);
|
return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch);
|
||||||
}, [liveRows, selectedBatch]);
|
}, [liveRows, selectedBatch, selectedTimeField, BATCHES]);
|
||||||
|
|
||||||
// Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes.
|
// Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes.
|
||||||
const liveData = useMemo(() => {
|
const liveData = useMemo(() => {
|
||||||
@@ -822,15 +1047,15 @@ const Dispatch = ({
|
|||||||
const tick = () => {
|
const tick = () => {
|
||||||
const h = dayjs().hour();
|
const h = dayjs().hour();
|
||||||
if (h === prevHourRef.current) return;
|
if (h === prevHourRef.current) return;
|
||||||
const fromSlot = getBatchForHour(prevHourRef.current);
|
const fromSlot = getBatchForHour(prevHourRef.current, BATCHES);
|
||||||
prevHourRef.current = h;
|
prevHourRef.current = h;
|
||||||
const toSlot = getBatchForHour(h);
|
const toSlot = getBatchForHour(h, BATCHES);
|
||||||
if (!toSlot || toSlot === fromSlot) return;
|
if (!toSlot || toSlot === fromSlot) return;
|
||||||
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
|
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
|
||||||
};
|
};
|
||||||
const id = setInterval(tick, 30 * 1000);
|
const id = setInterval(tick, 30 * 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [shouldFetchLive]);
|
}, [shouldFetchLive, BATCHES]);
|
||||||
|
|
||||||
// Reset focusedStop when the focused kitchen changes so a stale stop from a
|
// Reset focusedStop when the focused kitchen changes so a stale stop from a
|
||||||
// previously focused kitchen doesn't linger after switching kitchens.
|
// previously focused kitchen doesn't linger after switching kitchens.
|
||||||
@@ -1334,6 +1559,162 @@ const Dispatch = ({
|
|||||||
{shouldFetchLive && viewMode !== 'rider-info' && (
|
{shouldFetchLive && viewMode !== 'rider-info' && (
|
||||||
<div id="batch-row">
|
<div id="batch-row">
|
||||||
<span className="batch-label">Slot</span>
|
<span className="batch-label">Slot</span>
|
||||||
|
{/* Dropdown to pick which timestamp drives slot bucketing. Mirrors
|
||||||
|
the hub-location dropdown's look so it reads as the same kind of
|
||||||
|
filter control. The chosen field reruns batchCounts +
|
||||||
|
filteredLiveRows via selectedTimeField. */}
|
||||||
|
<div className="time-field-wrap" ref={timeFieldMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`time-field-btn ${timeFieldMenuOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => setTimeFieldMenuOpen((v) => !v)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={timeFieldMenuOpen}
|
||||||
|
title="Bucket slots by this timestamp"
|
||||||
|
>
|
||||||
|
<MdAccessTime />
|
||||||
|
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivery'}</span>
|
||||||
|
<MdExpandMore className="time-field-caret" />
|
||||||
|
</button>
|
||||||
|
{timeFieldMenuOpen && (
|
||||||
|
<div className="time-field-menu" role="listbox">
|
||||||
|
{TIME_FIELDS.map((f) => {
|
||||||
|
const isActive = f.id === selectedTimeField;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
className={`time-field-option ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTimeField(f.id);
|
||||||
|
setTimeFieldMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAccessTime className="time-field-option-icon" />
|
||||||
|
<span>{f.label}</span>
|
||||||
|
{isActive && <span className="time-field-option-check">✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Slot editor — lets the operator tweak start/end hours, add a new
|
||||||
|
slot, remove an existing one, or reset to defaults. Persists via
|
||||||
|
SLOTS_STORAGE_KEY in localStorage. */}
|
||||||
|
<div className="slot-edit-wrap" ref={slotEditRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`slot-edit-btn ${slotEditOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => setSlotEditOpen((v) => !v)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={slotEditOpen}
|
||||||
|
title="Adjust slot timings"
|
||||||
|
>
|
||||||
|
<MdAccessTime />
|
||||||
|
<span>Edit slots</span>
|
||||||
|
</button>
|
||||||
|
{slotEditOpen && (
|
||||||
|
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
|
||||||
|
<div className="slot-edit-head">
|
||||||
|
<div className="slot-edit-title">Slot timings</div>
|
||||||
|
<div className="slot-edit-sub">Hours are 0–24 (24h clock). Start < End.</div>
|
||||||
|
</div>
|
||||||
|
<div className="slot-edit-list">
|
||||||
|
{slotsConfig.map((s, idx) => (
|
||||||
|
<div key={s.id} className="slot-edit-row">
|
||||||
|
<span className="slot-edit-idx">{idx + 1}</span>
|
||||||
|
<label className="slot-edit-field">
|
||||||
|
<span className="slot-edit-field-label">Start</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
step={1}
|
||||||
|
value={s.startHour}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Math.max(0, Math.min(23, parseInt(e.target.value, 10) || 0));
|
||||||
|
setSlotsConfig((cur) => cur.map((row, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
|
||||||
|
: row
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="slot-edit-field">
|
||||||
|
<span className="slot-edit-field-label">End</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
value={s.endHour}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Math.max(1, Math.min(24, parseInt(e.target.value, 10) || 1));
|
||||||
|
setSlotsConfig((cur) => cur.map((row, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
|
||||||
|
: row
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="slot-edit-preview" title={`${formatSlotLabel(idx, s.startHour)} — ${formatSlotRange(s.startHour, s.endHour)}`}>
|
||||||
|
{formatSlotRange(s.startHour, s.endHour)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="slot-edit-remove"
|
||||||
|
onClick={() => setSlotsConfig((cur) => cur.filter((_, i) => i !== idx).map((row, i) => ({
|
||||||
|
...row,
|
||||||
|
id: `slot-${i + 1}`,
|
||||||
|
label: formatSlotLabel(i, row.startHour)
|
||||||
|
})))}
|
||||||
|
disabled={slotsConfig.length <= 1}
|
||||||
|
title={slotsConfig.length <= 1 ? 'Keep at least one slot' : 'Remove this slot'}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="slot-edit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="slot-edit-add"
|
||||||
|
onClick={() => setSlotsConfig((cur) => {
|
||||||
|
const last = cur[cur.length - 1];
|
||||||
|
const start = Math.min(23, (last?.endHour ?? 0));
|
||||||
|
const end = Math.min(24, start + 1);
|
||||||
|
const i = cur.length;
|
||||||
|
return [
|
||||||
|
...cur,
|
||||||
|
{
|
||||||
|
id: `slot-${i + 1}`,
|
||||||
|
startHour: start,
|
||||||
|
endHour: end,
|
||||||
|
label: formatSlotLabel(i, start),
|
||||||
|
range: formatSlotRange(start, end)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
+ Add slot
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="slot-edit-reset"
|
||||||
|
onClick={() => setSlotsConfig(BATCHES_DEFAULT)}
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
|
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
|
||||||
horizontally when it overflows. */}
|
horizontally when it overflows. */}
|
||||||
<div className="batch-scroll">
|
<div className="batch-scroll">
|
||||||
@@ -1556,8 +1937,26 @@ const Dispatch = ({
|
|||||||
attribution='© OpenStreetMap contributors'
|
attribution='© OpenStreetMap contributors'
|
||||||
/>
|
/>
|
||||||
<Marker position={[lat, lon]}>
|
<Marker position={[lat, lon]}>
|
||||||
|
{/* Permanent banner above the pin — Nominatim
|
||||||
|
reverse-geocode tells the operator which
|
||||||
|
suburb/area the rider is in. Falls back to
|
||||||
|
a "Locating…" hint while the request is in
|
||||||
|
flight so the pin never looks unlabeled. */}
|
||||||
|
<Tooltip
|
||||||
|
direction="top"
|
||||||
|
offset={[0, -10]}
|
||||||
|
permanent
|
||||||
|
className="ri-area-banner"
|
||||||
|
>
|
||||||
|
{riderInfoArea?.area || 'Locating area…'}
|
||||||
|
</Tooltip>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div style={{ fontWeight: 700, marginBottom: 2 }}>{d.username || `Rider #${d.userid}`}</div>
|
<div style={{ fontWeight: 700, marginBottom: 2 }}>{d.username || `Rider #${d.userid}`}</div>
|
||||||
|
{riderInfoArea?.area && (
|
||||||
|
<div style={{ fontSize: 12, color: '#0f172a', marginBottom: 4 }}>
|
||||||
|
{riderInfoArea.area}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ fontSize: 11, color: '#64748b' }}>
|
<div style={{ fontSize: 11, color: '#64748b' }}>
|
||||||
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
|
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
|
||||||
</div>
|
</div>
|
||||||
@@ -1761,9 +2160,9 @@ const Dispatch = ({
|
|||||||
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
|
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
{(o.deliverysuburb || o.deliveryaddress) && (
|
||||||
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
|
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
|
||||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{o.ordernotes && (
|
{o.ordernotes && (
|
||||||
@@ -1809,83 +2208,91 @@ const Dispatch = ({
|
|||||||
<span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
|
<span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
|
||||||
<span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
|
<span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="step-wrap">
|
{/* Render the kitchen's orders with the same zone-order-card layout
|
||||||
{focusedKitchen.orders.map((o, idx) => {
|
used by the focused-zone view, so By Location, By Zone, and By
|
||||||
const lat = parseFloat(o.droplat || o.deliverylat);
|
Rider all look consistent. Kitchen name is omitted from each
|
||||||
const lon = parseFloat(o.droplon || o.deliverylong);
|
card because the focused kitchen header already provides it. */}
|
||||||
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
<div className="zone-detail-section">
|
||||||
const isActive = focusedStop && focusedStop.orderid === o.orderid;
|
<div className="zone-section-label">Orders <span className="section-count">({focusedKitchen.orders.length})</span></div>
|
||||||
return (
|
{focusedKitchen.orders.length === 0 ? (
|
||||||
<div
|
<div className="zone-suburb-panel-empty">No orders for this kitchen.</div>
|
||||||
key={o.orderid}
|
) : (
|
||||||
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''}`}
|
<div className="zone-order-grid">
|
||||||
role={canFocus ? 'button' : undefined}
|
{focusedKitchen.orders.map((o, idx) => {
|
||||||
tabIndex={canFocus ? 0 : undefined}
|
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||||
onClick={canFocus ? () => setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
|
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||||
onKeyDown={canFocus ? (e) => {
|
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||||
e.preventDefault();
|
const statusStyle = getStatusStyle(o.orderstatus);
|
||||||
setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon });
|
const profit = parseFloat(o.profit || 0);
|
||||||
}
|
const isLoss = profit < 0;
|
||||||
} : undefined}
|
return (
|
||||||
title={canFocus ? (isActive ? 'Click to show full kitchen view' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
|
<div
|
||||||
>
|
key={o.orderid}
|
||||||
<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>
|
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''}`}
|
||||||
<div className="step-col-body">
|
role={canFocus ? 'button' : undefined}
|
||||||
<div className="step-label step-label-row">
|
tabIndex={canFocus ? 0 : undefined}
|
||||||
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
|
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
|
||||||
{o.orderstatus && (() => {
|
>
|
||||||
const s = getStatusStyle(o.orderstatus);
|
<div className="zone-order-card-head">
|
||||||
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
|
<div className="zone-order-num">{idx + 1}</div>
|
||||||
return (
|
<div className="zone-order-id-block">
|
||||||
<span className="step-flag">
|
<div className="zone-order-id">Order #{o.orderid}</div>
|
||||||
<svg className="step-flag-svg" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<div className="zone-order-rider">
|
||||||
<line x1="1.5" y1="0" x2="1.5" y2="18" stroke="#0f172a" strokeWidth="1.4" strokeLinecap="round" />
|
<Ico><MdTwoWheeler /></Ico>{o.rider_name || o.ridername || 'Unassigned'}
|
||||||
<polygon points="2,1 13,1 10,5 13,9 2,9" fill={s.bg} stroke="#0f172a" strokeWidth="0.5" strokeLinejoin="round" />
|
</div>
|
||||||
{isDel && (
|
</div>
|
||||||
<polyline points="4,5 6,7 9,3" fill="none" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
{o.orderstatus && (
|
||||||
)}
|
<span
|
||||||
</svg>
|
className="zone-order-status"
|
||||||
<span className="step-flag-label" style={{ color: s.bg }}>{s.label}</span>
|
style={{ background: statusStyle.bg, color: statusStyle.fg }}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="zone-order-customer">
|
||||||
|
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(o.deliverysuburb || o.deliveryaddress) && (
|
||||||
|
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
|
||||||
|
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{o.ordernotes && (
|
||||||
|
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
|
||||||
|
<Ico><MdNotes /></Ico>{o.ordernotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="zone-order-stats">
|
||||||
|
<span className="zone-order-chip" title="Distance">
|
||||||
|
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
|
||||||
</span>
|
</span>
|
||||||
);
|
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||||
})()}
|
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||||
</div>
|
</span>
|
||||||
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div>
|
{o.deliverycharge != null && (
|
||||||
{/* In the By-Kitchen view we show the customer's delivery address,
|
<span className="zone-order-chip" title="Delivery charge">
|
||||||
not the kitchen's location (locationname/locationsuburb describe
|
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||||||
the pickup spot, which is redundant when the kitchen is already
|
</span>
|
||||||
the focused context). */}
|
)}
|
||||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
{o.ordertype && (
|
||||||
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
|
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
|
||||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
{o.ordertype}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="zone-order-chip zone-order-trip">
|
||||||
|
T{o.trip_number || '-'} · S{o.step || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
{o.ordernotes && (
|
})}
|
||||||
<div className="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
|
|
||||||
)}
|
|
||||||
<div className="step-detail">
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{o.ordertype && (
|
|
||||||
<span className={`step-type type-${String(o.ordertype).toLowerCase()}`}>{o.ordertype}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1949,9 +2356,9 @@ const Dispatch = ({
|
|||||||
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
|
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
{(o.deliverysuburb || o.deliveryaddress) && (
|
||||||
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
|
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
|
||||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{o.ordernotes && (
|
{o.ordernotes && (
|
||||||
@@ -2202,6 +2609,59 @@ const Dispatch = ({
|
|||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
|
||||||
|
Reports → Riders Logs map: green pin when the rider's last log
|
||||||
|
row is `active`, red otherwise, with the rider's username as a
|
||||||
|
label. Scoped to riders who actually have orders in the
|
||||||
|
currently selected slot — `riders` is derived from
|
||||||
|
filteredLiveRows so it already reflects the slot filter. A
|
||||||
|
rider with zero orders in the current slot is hidden, even if
|
||||||
|
getriderlogs still returns their GPS row. When a specific
|
||||||
|
rider is focused, only that one is shown. */}
|
||||||
|
{liveRiderLocations
|
||||||
|
.filter((r) => riders.some((rd) => String(rd.id) === String(r.id)))
|
||||||
|
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
|
||||||
|
.map((r) => {
|
||||||
|
const isActive = r.status === 'active';
|
||||||
|
const pinColor = isActive ? '#16a34a' : '#dc2626';
|
||||||
|
const liveIcon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [140, 56],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [58, -40],
|
||||||
|
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
|
||||||
|
<div class="live-rider-pin-marker"></div>
|
||||||
|
<div class="live-rider-pin-label">${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` <span>#${String(r.orderid).replace(/[<>&"']/g, '')}</span>` : ''}</div>
|
||||||
|
</div>`
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`live-${r.id}`}
|
||||||
|
position={[r.lat, r.lon]}
|
||||||
|
icon={liveIcon}
|
||||||
|
zIndexOffset={2500}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => {
|
||||||
|
const match = riders.find((rd) => String(rd.id) === String(r.id));
|
||||||
|
if (match) handleRiderFocus(match);
|
||||||
|
},
|
||||||
|
mouseover: (e) => e.target.openPopup(),
|
||||||
|
mouseout: (e) => e.target.closePopup()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup maxWidth={220}>
|
||||||
|
<div className="pu-id">LIVE GPS</div>
|
||||||
|
<div className="pu-rider" style={{ color: pinColor }}>{r.username}</div>
|
||||||
|
<div className="pu-row"><span>Status</span><span>{r.status || 'unknown'}</span></div>
|
||||||
|
{r.orderid && <div className="pu-row"><span>Order</span><span>#{r.orderid}</span></div>}
|
||||||
|
{r.contactno && <div className="pu-row"><span>Phone</span><span>{r.contactno}</span></div>}
|
||||||
|
{r.logdate && <div className="pu-row"><span>Last seen</span><span>{r.logdate}</span></div>}
|
||||||
|
<div className="pu-row"><span>Position</span><span>{r.lat.toFixed(5)}, {r.lon.toFixed(5)}</span></div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
<div id="ov-tl">
|
<div id="ov-tl">
|
||||||
|
|||||||
Reference in New Issue
Block a user