update on the pickup and edit slot in the dispatch page
This commit is contained in:
@@ -544,6 +544,330 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
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); }
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.testing-container #body {
|
||||
flex: 1;
|
||||
@@ -2136,15 +2514,24 @@
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin-top: 3px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
/* Force a single-line, ellipsised row — long unstructured addresses used to
|
||||
wrap to 2-3 lines and made cards look noisy. Full address still surfaces
|
||||
on hover via the `title` attribute. */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.testing-container .zone-order-notes {
|
||||
font-style: italic;
|
||||
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 */
|
||||
@@ -3557,6 +3944,27 @@
|
||||
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 */
|
||||
@media (max-width: 600px) {
|
||||
.testing-container .rider-info-mode {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 'leaflet/dist/leaflet.css';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
MdPower,
|
||||
MdSearch
|
||||
} from 'react-icons/md';
|
||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs } from '../../api/api';
|
||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api';
|
||||
import './Dispatch.css';
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 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));
|
||||
// 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.
|
||||
@@ -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)
|
||||
// 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.
|
||||
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-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 },
|
||||
@@ -79,25 +106,65 @@ const BATCHES = [
|
||||
{ id: 'slot-5', label: 'Slot 5 · 8 PM', range: 'After 8 PM', startHour: 20, endHour: 24 }
|
||||
];
|
||||
|
||||
const getBatchForHour = (h) => {
|
||||
for (const b of BATCHES) {
|
||||
const SLOTS_STORAGE_KEY = 'dispatch.slots.v1';
|
||||
|
||||
// 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;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getRowBatch = (r) => {
|
||||
// Filter by actual delivery time first; fall back to expected delivery time only
|
||||
// when the order hasn't been completed yet. Both are real delivery-clock fields —
|
||||
// not arrival/assign/pickup timestamps, which led to mis-bucketing earlier.
|
||||
const t = r.deliverytime || r.expecteddeliverytime;
|
||||
// Time fields the operator can pick from to drive slot bucketing. Each
|
||||
// option maps to a column on the delivery row; the chosen one becomes the
|
||||
// timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime
|
||||
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
|
||||
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;
|
||||
const str = String(t).trim();
|
||||
// Skip bare date strings — no time component, would always parse to midnight.
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||
const d = dayjs(t);
|
||||
if (!d.isValid()) return null;
|
||||
return getBatchForHour(d.hour());
|
||||
return getBatchForHour(d.hour(), batches);
|
||||
};
|
||||
|
||||
const FINAL_STATUSES = new Set(['delivered']);
|
||||
@@ -334,6 +401,56 @@ const Dispatch = ({
|
||||
const [locationMenuOpen, setLocationMenuOpen] = useState(false);
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (!locationMenuOpen) return;
|
||||
@@ -346,6 +463,30 @@ const Dispatch = ({
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [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
|
||||
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
|
||||
// ('rider-info') so it follows the same toggle pattern as the other modes.
|
||||
@@ -369,6 +510,41 @@ const Dispatch = ({
|
||||
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(() => {
|
||||
if (!appLocations) return null;
|
||||
const match = appLocations.find((l) => String(l.applocationid) === String(selectedAppLocationId));
|
||||
@@ -398,11 +574,59 @@ const Dispatch = ({
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [animatedSegments, setAnimatedSegments] = useState([]);
|
||||
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
|
||||
// window (e.g. before 8 AM or in the 11–12 gap) fall back to the first slot.
|
||||
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);
|
||||
|
||||
// Live deliveries query — runs only when no `data` prop is passed (i.e., standalone page).
|
||||
@@ -467,22 +691,23 @@ const Dispatch = ({
|
||||
}, [liveRows]);
|
||||
|
||||
// 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 counts = { all: liveRows.length };
|
||||
BATCHES.forEach((b) => { counts[b.id] = 0; });
|
||||
liveRows.forEach((r) => {
|
||||
const b = getRowBatch(r);
|
||||
const b = getRowBatch(r, selectedTimeField, BATCHES);
|
||||
if (b) counts[b] = (counts[b] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [liveRows]);
|
||||
}, [liveRows, selectedTimeField, BATCHES]);
|
||||
|
||||
// Apply the batch filter before grouping so zones/riders/bikes all reflect the chosen wave.
|
||||
const filteredLiveRows = useMemo(() => {
|
||||
if (selectedBatch === 'all') return liveRows;
|
||||
return liveRows.filter((r) => getRowBatch(r) === selectedBatch);
|
||||
}, [liveRows, selectedBatch]);
|
||||
return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch);
|
||||
}, [liveRows, selectedBatch, selectedTimeField, BATCHES]);
|
||||
|
||||
// Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes.
|
||||
const liveData = useMemo(() => {
|
||||
@@ -822,15 +1047,15 @@ const Dispatch = ({
|
||||
const tick = () => {
|
||||
const h = dayjs().hour();
|
||||
if (h === prevHourRef.current) return;
|
||||
const fromSlot = getBatchForHour(prevHourRef.current);
|
||||
const fromSlot = getBatchForHour(prevHourRef.current, BATCHES);
|
||||
prevHourRef.current = h;
|
||||
const toSlot = getBatchForHour(h);
|
||||
const toSlot = getBatchForHour(h, BATCHES);
|
||||
if (!toSlot || toSlot === fromSlot) return;
|
||||
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
|
||||
};
|
||||
const id = setInterval(tick, 30 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [shouldFetchLive]);
|
||||
}, [shouldFetchLive, BATCHES]);
|
||||
|
||||
// Reset focusedStop when the focused kitchen changes so a stale stop from a
|
||||
// previously focused kitchen doesn't linger after switching kitchens.
|
||||
@@ -1334,6 +1559,162 @@ const Dispatch = ({
|
||||
{shouldFetchLive && viewMode !== 'rider-info' && (
|
||||
<div id="batch-row">
|
||||
<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
|
||||
horizontally when it overflows. */}
|
||||
<div className="batch-scroll">
|
||||
@@ -1556,8 +1937,26 @@ const Dispatch = ({
|
||||
attribution='© OpenStreetMap contributors'
|
||||
/>
|
||||
<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>
|
||||
<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' }}>
|
||||
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
|
||||
</div>
|
||||
@@ -1761,9 +2160,9 @@ const Dispatch = ({
|
||||
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
|
||||
</div>
|
||||
)}
|
||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
||||
{(o.deliverysuburb || o.deliveryaddress) && (
|
||||
<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>
|
||||
)}
|
||||
{o.ordernotes && (
|
||||
@@ -1809,84 +2208,92 @@ const Dispatch = ({
|
||||
<span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
|
||||
<span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
|
||||
</div>
|
||||
<div className="step-wrap">
|
||||
{/* Render the kitchen's orders with the same zone-order-card layout
|
||||
used by the focused-zone view, so By Location, By Zone, and By
|
||||
Rider all look consistent. Kitchen name is omitted from each
|
||||
card because the focused kitchen header already provides it. */}
|
||||
<div className="zone-detail-section">
|
||||
<div className="zone-section-label">Orders <span className="section-count">({focusedKitchen.orders.length})</span></div>
|
||||
{focusedKitchen.orders.length === 0 ? (
|
||||
<div className="zone-suburb-panel-empty">No orders for this kitchen.</div>
|
||||
) : (
|
||||
<div className="zone-order-grid">
|
||||
{focusedKitchen.orders.map((o, idx) => {
|
||||
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||
const isActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||
const statusStyle = getStatusStyle(o.orderstatus);
|
||||
const profit = parseFloat(o.profit || 0);
|
||||
const isLoss = profit < 0;
|
||||
return (
|
||||
<div
|
||||
key={o.orderid}
|
||||
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''}`}
|
||||
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? '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}
|
||||
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
|
||||
>
|
||||
<div className="step-col-left"><div className="step-dot delivery" style={{ background: getRiderColor(o.rider_id), color: '#fff', borderColor: getRiderColor(o.rider_id) }}>{idx + 1}</div></div>
|
||||
<div className="step-col-body">
|
||||
<div className="step-label step-label-row">
|
||||
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
|
||||
{o.orderstatus && (() => {
|
||||
const s = getStatusStyle(o.orderstatus);
|
||||
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
|
||||
return (
|
||||
<span className="step-flag">
|
||||
<svg className="step-flag-svg" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="1.5" y1="0" x2="1.5" y2="18" stroke="#0f172a" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<polygon points="2,1 13,1 10,5 13,9 2,9" fill={s.bg} stroke="#0f172a" strokeWidth="0.5" strokeLinejoin="round" />
|
||||
{isDel && (
|
||||
<polyline points="4,5 6,7 9,3" fill="none" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
<span className="step-flag-label" style={{ color: s.bg }}>{s.label}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<div className="zone-order-card-head">
|
||||
<div className="zone-order-num">{idx + 1}</div>
|
||||
<div className="zone-order-id-block">
|
||||
<div className="zone-order-id">Order #{o.orderid}</div>
|
||||
<div className="zone-order-rider">
|
||||
<Ico><MdTwoWheeler /></Ico>{o.rider_name || o.ridername || 'Unassigned'}
|
||||
</div>
|
||||
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div>
|
||||
{/* In the By-Kitchen view we show the customer's delivery address,
|
||||
not the kitchen's location (locationname/locationsuburb describe
|
||||
the pickup spot, which is redundant when the kitchen is already
|
||||
the focused context). */}
|
||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
||||
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
|
||||
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
|
||||
</div>
|
||||
{o.orderstatus && (
|
||||
<span
|
||||
className="zone-order-status"
|
||||
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="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
|
||||
<div className="zone-order-line zone-order-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)}
|
||||
|
||||
<div className="zone-order-stats">
|
||||
<span className="zone-order-chip" title="Distance">
|
||||
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
|
||||
</span>
|
||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{o.deliverycharge != null && (
|
||||
<span className="step-charges">₹{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
|
||||
<span className="zone-order-chip" title="Delivery charge">
|
||||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||||
</span>
|
||||
)}
|
||||
{o.ordertype && (
|
||||
<span className={`step-type type-${String(o.ordertype).toLowerCase()}`}>{o.ordertype}</span>
|
||||
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
|
||||
{o.ordertype}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="zone-order-chip zone-order-trip">
|
||||
T{o.trip_number || '-'} · S{o.step || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1949,9 +2356,9 @@ const Dispatch = ({
|
||||
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
|
||||
</div>
|
||||
)}
|
||||
{(o.deliveryaddress || o.deliverysuburb) && (
|
||||
{(o.deliverysuburb || o.deliveryaddress) && (
|
||||
<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>
|
||||
)}
|
||||
{o.ordernotes && (
|
||||
@@ -2202,6 +2609,59 @@ const Dispatch = ({
|
||||
</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>
|
||||
|
||||
<div id="ov-tl">
|
||||
|
||||
Reference in New Issue
Block a user