updates on the bookings page and updated ai and more thngs

This commit is contained in:
2026-09-11 11:20:38 +05:30
parent 1caa7b71a9
commit 49ee0c5076
40 changed files with 5796 additions and 359 deletions

View File

@@ -19,6 +19,7 @@ const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx'));
const Orders = lazy(() => import('@/pages/doormile/orders/Orders'));
const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings'));
const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview'));
const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
@@ -93,6 +94,8 @@ const AuthenticatedApp = () => {
<Route path="orders/createorders" element={<MultipleOrders />} />
<Route path="orders/preview" element={<OrdersPreview />} />
<Route path="bookings" element={<Bookings />} />
<Route path="deliveries" element={<Deliveries />} />
<Route path="tenants" element={<Tenants />} />

View File

@@ -404,9 +404,12 @@ export const getBookings = async (pageno, pagesize) => {
//
// getBookings returns `response.data.data` — just the rows — which means no
// caller can tell a full result from a truncated one. `pagesize` is capped at
// 1000 server-side (express-console-api.md, "Conventions": default 500, cap
// 1000), so any account with more than 1000 bookings silently gets a partial
// list from a single call. That's tolerable for a paginated table (it fetches
// 100 server-side, and the default is 20 — NOT "default 500, cap 1000", which
// is what express-console-api.md said until it was corrected. Asking for 1000
// returns 100 rows and an envelope whose own `pagesize` reads 100, so the
// under-fetch is silent unless you check it. Any account with more than 100
// bookings therefore gets a partial list from a single call. That's tolerable
// for a paginated table (it fetches
// the next page as you scroll) but NOT for anything that counts or sums, which
// would report a confidently wrong number.
//

View File

@@ -2,6 +2,7 @@ import axios from 'axios';
import { OpenToast } from './notify';
import logger from '@/lib/logger';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { buildAnchors, classifyBooking } from '@/lib/orderFlow';
import {
getMilers,
getMiler,
@@ -699,6 +700,81 @@ export const notifyRider = async (
// page already reads (orderheaderid, deliverycustomer, ridername, etc.) so
// deliveries.js itself doesn't need a full rewrite — same compatibility-shim
// approach as fetchAllRiders' `{details}` wrapper.
// ==============================|| Facility anchors for flow classification ||============================== //
//
// classifyBooking needs the list of places that count as "a facility, not a
// customer's door": Doormile's own hubs (GET /admin/hubs) plus each client's
// own origins (GET /admin/tenants/:id/locations). Without the second list a
// tenant's kitchen would classify as a customer pickup, which would relabel
// every historical order on the board — so both are required, not optional.
//
// Cached for the life of the session because fetchDeliveries runs once PER
// PAGE of bookings. Re-fetching a tenant's locations on every page of a
// 2,000-order day would add one request per tenant per page for reference data
// that changes about monthly.
//
// Every call is guarded. If an anchor list fails to load, classification
// degrades to "everything is a hub pickup" — precisely what the console did
// before this existed — rather than failing the page.
let hubAnchorSourcePromise = null;
const tenantLocationSourcePromises = new Map();
// Always resolves to an array. Guards the throw, the rejected promise AND the
// call that returns nothing at all — this runs on the deliveries path, which is
// the one thing fetchDeliveries cannot recover from, so a reference list that
// misbehaves must degrade classification rather than fail the page.
const safeAnchorList = async (fetcher) => {
try {
const result = await fetcher();
return Array.isArray(result) ? result : [];
} catch {
return [];
}
};
const loadHubAnchorSources = () => {
if (!hubAnchorSourcePromise) {
hubAnchorSourcePromise = safeAnchorList(() => getHubs());
}
return hubAnchorSourcePromise;
};
const loadTenantLocationSources = (tenantId) => {
const key = String(tenantId);
if (!tenantLocationSourcePromises.has(key)) {
tenantLocationSourcePromises.set(key, safeAnchorList(() => getTenantLocations(tenantId)));
}
return tenantLocationSourcePromises.get(key);
};
/** Drop the cached facility lists. Call after creating or editing a hub or a
* tenant location, otherwise a newly-added kitchen keeps classifying its own
* orders as customer pickups until the tab is reloaded. */
export const resetFlowAnchorCache = () => {
hubAnchorSourcePromise = null;
tenantLocationSourcePromises.clear();
};
/**
* The facility anchors for a set of tenants.
*
* Exported because pages that read RAW bookings — Orders, for one — have to
* classify with exactly the same facility list as the deliveries feed does.
* Two pages deriving anchors from two different lists is how the same order
* ends up labelled a customer pickup on one screen and a hub run on the other.
*/
export const fetchFlowAnchors = async (tenantIds = []) => {
const ids = [...new Set((tenantIds || []).filter((id) => id != null))];
const [hubs, locationLists] = await Promise.all([
loadHubAnchorSources(),
Promise.all(ids.map((id) => loadTenantLocationSources(id)))
]);
const tenantLocations = (locationLists || []).flatMap((list) => (Array.isArray(list) ? list : []));
return buildAnchors({ hubs, tenantLocations });
};
const loadFlowAnchors = (bookings) => fetchFlowAnchors((bookings || []).map((b) => b?.tenantid));
export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// Every caller builds the key as
// [name, appId, userid, status, startdate, enddate, rowsPerPage, …]
@@ -819,6 +895,10 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
const dispatched = (bookings || []).filter(isDispatched);
// Which end of each booking is a facility. Loaded from the cache, so this is
// a no-op network-wise on every page after the first.
const flowAnchors = await loadFlowAnchors(dispatched);
const rows = dispatched.map((b) => {
const consignmentId = b.consignmentid ?? b.consignment_id;
const consignment = consignmentId != null ? consignmentMap.get(String(consignmentId)) : null;
@@ -844,6 +924,7 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
const cStatus = consignmentStatusFor(b, consignmentMap);
const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
const effectiveDeliveryStatus = deriveDeliveryStatus(b, consignmentMap);
const flow = classifyBooking(b, flowAnchors);
const actualRiderName =
miler?.resolvedName ||
@@ -870,6 +951,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
consignmentid: consignmentId,
hubid: b.hubid,
sourcehubid: b.sourcehubid,
// The hub that OWNS this order for zone filtering and reporting, which
// is NOT the same question as "where is it collected from". On a customer
// pickup the collection point is a doorstep and the owning hub is the one
// the parcel is inbound to; on a P2P run neither end is a facility and
// this stays null, leaving ZoneContext to fall back to its radius match.
servicinghubid: flow.servicingHubId,
tenantid: b.tenantid,
tenantname: tenant?.tenantname || '',
tenantsuburb: '',
@@ -879,7 +966,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
tenantadress: tenant?.primaryemail || '',
locationname: tenant?.tenantname || '',
locationsuburb: '',
pickupcustomer: tenant?.tenantname || '',
// Which flow this order is, and what its collection point actually is.
//
// `pickupcustomer` used to be the TENANT NAME, which made every order of
// a client claim the same collection point no matter where it was picked
// up. Dispatch groups its kitchen pins on this, so N customer pickups for
// one client collapsed into a single pin sitting on whichever of them
// happened to be first in the array. It is now the real point: the
// facility's name for a hub pickup, the doorstep for a customer pickup.
ordertype: flow.flow,
ordertypelabel: flow.flowLabel,
pickupsource: flow.pickupSource,
droptarget: flow.dropTarget,
// The STABLE grouping key — one per facility, but one per BOOKING for a
// customer pickup, because two customer doors are two different places.
// Group on this, never on the display name: two hubs can share a name.
pickuppointkey: flow.pickupPointKey,
pickupcustomer: flow.pickupPointName || tenant?.tenantname || '',
pickupcontactno: '',
Pickupaddress: b.pickupaddress || '',
pickupaddress: b.pickupaddress || '',

View File

@@ -1,8 +1,13 @@
import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, MapPin, X } from 'lucide-react';
import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react';
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
export const toPlace = (result) => standardizePlace(result);
export const geocodeAddress = async (address, options = {}) => {
return await serviceGeocodeAddress(address, options);
};
const useDebouncedCallback = (callback, delay) => {
const timeoutRef = useRef(null);
@@ -17,68 +22,14 @@ const useDebouncedCallback = (callback, delay) => {
);
};
const buildAddressComponents = (addr = {}) => {
const components = [];
const push = (longName, types) => {
if (longName) components.push({ long_name: longName, short_name: longName, types });
};
push(addr.house_number, ['street_number']);
push(addr.road || addr.pedestrian, ['route']);
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
push(addr.city_district || addr.county, ['administrative_area_level_3']);
push(addr.city || addr.town || addr.village, ['locality']);
push(addr.state, ['administrative_area_level_1']);
push(addr.country, ['country']);
push(addr.postcode, ['postal_code']);
return components;
};
export const toPlace = (result) => ({
formatted_address: result.display_name || '',
name: result.display_name?.split(',')[0] || '',
geometry: {
location: {
lat: () => parseFloat(result.lat),
lng: () => parseFloat(result.lon)
}
},
address_components: buildAddressComponents(result.address)
});
const buildParams = (query, bias) => {
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
if (bias?.lat && bias?.lng) {
const d = 0.5; // ~55km soft bias bounding box
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
}
return params;
};
export async function geocodeAddress(address, { bias } = {}) {
if (!address || !address.trim()) return null;
try {
const params = buildParams(address.trim(), bias);
params.set('limit', '1');
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
});
if (!res.ok) return null;
const results = await res.json();
if (!results?.length) return null;
return toPlace(results[0]);
} catch (err) {
console.error('geocodeAddress error:', err);
return null;
}
}
const AddressAutocomplete = ({
id,
label,
placeholder = 'Search address…',
placeholder = 'Search address or locality…',
value,
onChange,
onPlaceSelected,
onOpenMapPin,
bias,
fullWidth = true,
disabled = false,
@@ -90,14 +41,10 @@ const AddressAutocomplete = ({
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
/* Which suggestion the arrow keys are on. -1 is "none highlighted", the state
the list opens in, so Enter never picks a result the operator hasn't moved
to. */
const [activeOption, setActiveOption] = useState(-1);
const wrapRef = useRef(null);
const activeRef = useRef(0);
// Set by pick(); consumed by the search effect. See the comment there.
const skipNextSearchRef = useRef(false);
const listId = `${id || 'address'}-suggestions`;
const optionId = (index) => `${listId}-option-${index}`;
@@ -112,11 +59,7 @@ const AddressAutocomplete = ({
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
const params = buildParams(query, bias);
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
})
.then((res) => (res.ok ? res.json() : []))
getAddressSuggestions(query, { bias, limit: 6 })
.then((results) => {
if (seq !== activeRef.current) return;
setLoading(false);
@@ -128,19 +71,14 @@ const AddressAutocomplete = ({
setLoading(false);
setOptions([]);
});
}, 450);
}, 220);
useEffect(() => {
/* Picking a suggestion writes the resolved address back into inputValue,
which used to re-trigger this search: the list reopened over the form the
instant it was dismissed, and every selection cost a second Nominatim
request against a service that asks for one per second. The pick is not a
new query, so skip exactly one run. */
if (skipNextSearchRef.current) {
skipNextSearchRef.current = false;
return;
}
if (!inputValue || inputValue.length < 3) {
if (!inputValue || inputValue.length < 2) {
activeRef.current++;
setOptions([]);
setLoading(false);
@@ -151,8 +89,6 @@ const AddressAutocomplete = ({
fetchPredictions(inputValue);
}, [inputValue, biasKey]);
/* A fresh result set invalidates the highlight — otherwise index 2 of the old
list silently becomes index 2 of the new one. */
useEffect(() => {
setActiveOption(-1);
}, [options]);
@@ -168,23 +104,16 @@ const AddressAutocomplete = ({
}, []);
const pick = (option) => {
const formatted = option.display_name || '';
const formatted = option.formatted_address || option.name || '';
skipNextSearchRef.current = true;
activeRef.current++; // drop any in-flight response that would reopen the list
activeRef.current++;
setInputValue(formatted);
onChange?.(formatted);
onPlaceSelected?.(toPlace(option));
onPlaceSelected?.(option);
setIsOpen(false);
setActiveOption(-1);
};
/**
* Arrow keys, Enter and Escape over the suggestion list.
*
* Not a nicety: an order cannot be created from typed text — the submit gate
* requires the coordinates that only picking a suggestion supplies — so
* without this the whole create-order flow needed a mouse.
*/
const onKeyDown = (e) => {
const listOpen = isOpen && options.length > 0;
if (e.key === 'Escape') {
@@ -200,8 +129,6 @@ const AddressAutocomplete = ({
e.preventDefault();
setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
} else if (e.key === 'Enter' && activeOption >= 0) {
// Only swallow Enter when a suggestion is actually highlighted, so Enter
// on a bare query still submits the surrounding form.
e.preventDefault();
pick(options[activeOption]);
}
@@ -209,11 +136,25 @@ const AddressAutocomplete = ({
return (
<div ref={wrapRef} className={`relative ${className}`} style={{ width: fullWidth ? '100%' : undefined }}>
{label && (
<label htmlFor={id} className="block text-xs font-semibold text-slate-800 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<div className="flex items-center justify-between mb-1">
{label && (
<label htmlFor={id} className="block text-xs font-semibold text-slate-800">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
{onOpenMapPin && (
<button
type="button"
onClick={onOpenMapPin}
className="text-[11px] font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer transition-colors"
title="Choose point on map"
>
<Compass className="w-3 h-3" />
<span>Pin on Map</span>
</button>
)}
</div>
<div className="relative">
<input
id={id}
@@ -234,66 +175,94 @@ const AddressAutocomplete = ({
onKeyDown(e);
if (e.key === 'Escape') setIsOpen(false);
}}
className={`w-full h-9 pl-3 pr-8 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
className={`w-full h-9 pl-3 pr-16 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
/>
{loading ? (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</span>
) : inputValue && !disabled ? (
<button
type="button"
onClick={() => {
setInputValue('');
onChange?.('');
setIsOpen(false);
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5 rounded-full hover:bg-slate-100"
title="Clear address"
>
<X className="w-3 h-3" />
</button>
) : null}
{/* Action icons on the right */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{loading ? (
<span className="text-slate-400 p-0.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</span>
) : inputValue && !disabled ? (
<button
type="button"
onClick={() => {
setInputValue('');
onChange?.('');
setIsOpen(false);
}}
className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer"
title="Clear address"
>
<X className="w-3 h-3" />
</button>
) : null}
{onOpenMapPin && !disabled && (
<button
type="button"
onClick={onOpenMapPin}
className="text-slate-400 hover:text-black p-1 rounded-md hover:bg-slate-100 transition-colors cursor-pointer"
title="Open map to pin exact location"
>
<Navigation className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{isOpen && options.length > 0 && (
<ul
id={listId}
className="absolute z-[9999] left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-xl max-h-60 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
role="listbox"
aria-label="Address suggestions"
>
{options.map((option, index) => (
/* The option role sits on the <li>, which is what aria-activedescendant
points at; the button inside stays for the mouse. Without this the
popup announced as an empty listbox. */
<li
key={option.place_id}
id={optionId(index)}
role="option"
aria-selected={index === activeOption}
className={index === activeOption ? 'bg-slate-100' : undefined}
>
<button
type="button"
tabIndex={-1}
onMouseEnter={() => setActiveOption(index)}
onClick={() => pick(option)}
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer group"
{options.map((option, index) => {
const isHighlight = index === activeOption;
return (
<li
key={`${option.latitude}-${option.longitude}-${index}`}
id={optionId(index)}
role="option"
aria-selected={isHighlight}
className={isHighlight ? 'bg-slate-50' : undefined}
>
<MapPin className="w-3.5 h-3.5 text-slate-400 group-hover:text-black mt-0.5 shrink-0 transition-colors" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-slate-800 truncate">
{option.display_name?.split(',')[0]}
<button
type="button"
tabIndex={-1}
onMouseEnter={() => setActiveOption(index)}
onClick={() => pick(option)}
className="w-full text-left px-3.5 py-2.5 hover:bg-slate-50/80 flex items-start gap-2.5 transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-slate-100 group-hover:bg-black text-slate-500 group-hover:text-white flex items-center justify-center shrink-0 mt-0.5 transition-colors">
<MapPin className="w-3.5 h-3.5" />
</div>
<div className="text-[11px] text-slate-500 line-clamp-1">
{option.display_name}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-900 truncate">
{option.name || option.formatted_address.split(',')[0]}
</span>
{option.suburb && (
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-slate-100 text-slate-600 shrink-0">
{option.suburb}
</span>
)}
{option.postcode && (
<span className="text-[10px] font-mono text-slate-400 shrink-0">
{option.postcode}
</span>
)}
</div>
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
{option.formatted_address}
</div>
</div>
</div>
</button>
</li>
))}
</button>
</li>
);
})}
</ul>
)}
</div>
@@ -307,6 +276,7 @@ AddressAutocomplete.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
onPlaceSelected: PropTypes.func,
onOpenMapPin: PropTypes.func,
bias: PropTypes.object,
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,

View File

@@ -0,0 +1,350 @@
import PropTypes from 'prop-types';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import { Check, Crosshair, Loader2, MapPin, Search, X } from 'lucide-react';
import { getAddressSuggestions, reverseGeocode } from '@/lib/geocodingService';
// Fix default leaflet marker icon
const customMarkerIcon = new L.DivIcon({
className: 'custom-pin-marker',
html: `
<div style="position: relative; display: flex; flex-direction: column; align-items: center; transform: translate(-50%, -100%);">
<div style="background-color: #000000; color: #ffffff; padding: 6px; border-radius: 9999px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); border: 2px solid #ffffff;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
</div>
<div style="width: 2px; height: 8px; background-color: #000000;"></div>
<div style="width: 6px; height: 6px; border-radius: 9999px; background-color: rgba(0,0,0,0.4);"></div>
</div>
`,
iconSize: [32, 42],
iconAnchor: [16, 42]
});
// Map click listener component
function LocationMarker({ position, onLocationChange }) {
useMapEvents({
click(e) {
onLocationChange(e.latlng.lat, e.latlng.lng);
}
});
const markerRef = useRef(null);
const eventHandlers = useMemo(
() => ({
dragend() {
const marker = markerRef.current;
if (marker != null) {
const latlng = marker.getLatLng();
onLocationChange(latlng.lat, latlng.lng);
}
}
}),
[onLocationChange]
);
return position ? (
<Marker
draggable
eventHandlers={eventHandlers}
position={[position.lat, position.lng]}
ref={markerRef}
icon={customMarkerIcon}
/>
) : null;
}
LocationMarker.propTypes = {
position: PropTypes.shape({
lat: PropTypes.number,
lng: PropTypes.number
}),
onLocationChange: PropTypes.func.isRequired
};
// Map center adjuster
function ChangeMapView({ center, zoom }) {
const map = useMap();
useEffect(() => {
if (center && Number.isFinite(center[0]) && Number.isFinite(center[1])) {
map.setView(center, zoom || map.getZoom());
}
}, [center, zoom, map]);
return null;
}
ChangeMapView.propTypes = {
center: PropTypes.arrayOf(PropTypes.number),
zoom: PropTypes.number
};
export default function MapPinPickerModal({
isOpen,
onClose,
onConfirm,
initialLat,
initialLng,
initialAddress = '',
title = 'Pin Exact Location on Map'
}) {
// Default coordinates (Bengaluru / Central fallback if none provided)
const defaultLat = Number(initialLat) || 12.9716;
const defaultLng = Number(initialLng) || 77.5946;
const [coords, setCoords] = useState({ lat: defaultLat, lng: defaultLng });
const [mapCenter, setMapCenter] = useState([defaultLat, defaultLng]);
const [zoomLevel, setZoomLevel] = useState(initialLat && initialLng ? 16 : 13);
const [resolvedPlace, setResolvedPlace] = useState(null);
const [isResolving, setIsResolving] = useState(false);
// Search in modal
const [searchQuery, setSearchQuery] = useState(initialAddress || '');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isOpen) {
const lat = Number(initialLat) || 12.9716;
const lng = Number(initialLng) || 77.5946;
setCoords({ lat, lng });
setMapCenter([lat, lng]);
setZoomLevel(initialLat && initialLng ? 16 : 13);
setSearchQuery(initialAddress || '');
setSearchResults([]);
handleCoordUpdate(lat, lng);
}
}, [isOpen, initialLat, initialLng, initialAddress]);
const handleCoordUpdate = async (lat, lng) => {
setCoords({ lat, lng });
setIsResolving(true);
try {
const place = await reverseGeocode(lat, lng);
setResolvedPlace(place);
} catch (e) {
console.warn('Reverse geocode error:', e);
} finally {
setIsResolving(false);
}
};
const handleSearch = async (e) => {
e?.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const results = await getAddressSuggestions(searchQuery, { limit: 5 });
setSearchResults(results);
} catch (err) {
console.warn('Modal search failed:', err);
} finally {
setIsSearching(false);
}
};
const handlePickSearchResult = (result) => {
if (result.latitude && result.longitude) {
setMapCenter([result.latitude, result.longitude]);
setZoomLevel(16);
handleCoordUpdate(result.latitude, result.longitude);
setSearchResults([]);
setSearchQuery(result.formatted_address);
}
};
const handleCurrentLocation = () => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setMapCenter([lat, lng]);
setZoomLevel(17);
handleCoordUpdate(lat, lng);
},
(err) => {
console.warn('Geolocation error:', err);
}
);
}
};
const handleConfirm = () => {
if (!coords.lat || !coords.lng) return;
const finalPlace = resolvedPlace || {
formatted_address: initialAddress || `Coordinates (${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)})`,
name: 'Selected Pin',
latitude: coords.lat,
longitude: coords.lng,
suburb: '',
city: '',
postcode: ''
};
onConfirm?.(finalPlace);
onClose?.();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-3 sm:p-6 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-3xl overflow-hidden animate-in zoom-in-95 duration-150 flex flex-col h-[90vh] max-h-[700px]">
{/* Header */}
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center justify-between bg-white shrink-0">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-black text-white flex items-center justify-center">
<MapPin className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
<p className="text-[11px] text-slate-500">Click anywhere or drag the pin to set exact coordinates</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Search & Location Bar */}
<div className="p-3 bg-slate-50 border-b border-slate-200 shrink-0 relative">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search area, landmark or street to fly there…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-9 pl-9 pr-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black shadow-2xs"
/>
{isSearching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</span>
)}
</div>
<button
type="submit"
className="h-9 px-3.5 bg-black hover:bg-neutral-800 text-white text-xs font-semibold rounded-lg flex items-center gap-1.5 transition-colors cursor-pointer shadow-2xs"
>
Search
</button>
<button
type="button"
onClick={handleCurrentLocation}
title="Use current GPS location"
className="h-9 px-3 border border-slate-200 bg-white hover:bg-slate-100 text-slate-700 text-xs font-semibold rounded-lg flex items-center gap-1.5 transition-colors cursor-pointer shadow-2xs"
>
<Crosshair className="w-3.5 h-3.5 text-blue-600" />
<span className="hidden sm:inline">My GPS</span>
</button>
</form>
{/* Search suggestions dropdown in modal */}
{searchResults.length > 0 && (
<ul className="absolute z-50 left-3 right-3 mt-1 bg-white border border-slate-200 rounded-xl shadow-xl max-h-48 overflow-y-auto divide-y divide-slate-100">
{searchResults.map((item, idx) => (
<li key={idx}>
<button
type="button"
onClick={() => handlePickSearchResult(item)}
className="w-full text-left px-3.5 py-2 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer"
>
<MapPin className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-slate-800 truncate">{item.name}</div>
<div className="text-[11px] text-slate-500 truncate">{item.formatted_address}</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
{/* Leaflet Map Area */}
<div className="flex-1 relative bg-slate-100 overflow-hidden">
<MapContainer
center={mapCenter}
zoom={zoomLevel}
scrollWheelZoom
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ChangeMapView center={mapCenter} zoom={zoomLevel} />
<LocationMarker position={coords} onLocationChange={handleCoordUpdate} />
</MapContainer>
{/* Pin Instruction Overlay */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[400] bg-black/75 text-white text-[11px] font-medium px-3 py-1.5 rounded-full shadow-lg backdrop-blur-xs pointer-events-none flex items-center gap-1.5">
<MapPin className="w-3 h-3 text-emerald-400" />
<span>Click map or drag pin to position</span>
</div>
</div>
{/* Footer: Resolved Address & Confirmation */}
<div className="p-4 border-t border-slate-200 bg-white flex flex-col sm:flex-row items-center justify-between gap-3 shrink-0">
<div className="min-w-0 flex-1 w-full text-left">
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Selected Location</div>
{isResolving ? (
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<Loader2 className="w-3.5 h-3.5 animate-spin text-slate-400" />
<span>Identifying street address…</span>
</div>
) : (
<div className="text-xs font-semibold text-slate-900 truncate mt-0.5">
{resolvedPlace?.formatted_address || `${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}`}
</div>
)}
<div className="text-[10px] font-mono text-slate-400 mt-0.5">
Lat: {coords.lat.toFixed(6)}, Lng: {coords.lng.toFixed(6)}
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto shrink-0">
<button
type="button"
onClick={onClose}
className="flex-1 sm:flex-initial h-9 px-4 border border-slate-200 bg-white hover:bg-slate-50 text-slate-700 text-xs font-semibold rounded-lg transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
className="flex-1 sm:flex-initial h-9 px-5 bg-black hover:bg-neutral-800 text-white text-xs font-bold rounded-lg flex items-center justify-center gap-1.5 shadow-sm transition-all cursor-pointer"
>
<Check className="w-4 h-4" />
<span>Confirm Location</span>
</button>
</div>
</div>
</div>
</div>
);
}
MapPinPickerModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
initialLat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
initialLng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
initialAddress: PropTypes.string,
title: PropTypes.string
};

View File

@@ -42,6 +42,7 @@ export function DataTable({
/* Selection — pass `selectedIds` to enable */
selectedIds,
onSelectionChange,
selectAllPages = true,
/* Pagination — pass `pageSize` to enable */
pageSize: initialPageSize,
@@ -138,15 +139,22 @@ export function DataTable({
/* ── Selection ───────────────────────────────────────────────────────── */
const selectable = selectedIds !== undefined;
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
const allRowIds = React.useMemo(
() => sortedRows.map((row, i) => getRowId(row, i)),
[sortedRows, getRowId]
);
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
const someOnPageSelected = pageIds.some((id) => selected.has(id));
const targetIds = selectAllPages && !serverPaginated ? allRowIds : pageIds;
const allTargetSelected = targetIds.length > 0 && targetIds.every((id) => selected.has(id));
const someTargetSelected = targetIds.some((id) => selected.has(id));
const toggleAll = () => {
if (allOnPageSelected) {
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
if (allTargetSelected) {
onSelectionChange?.(selectedIds.filter((id) => !targetIds.includes(id)));
} else {
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
onSelectionChange?.([...new Set([...selectedIds, ...targetIds])]);
}
};
@@ -202,9 +210,13 @@ export function DataTable({
{selectable && (
<th scope="col" className={cn('w-10 px-4', cellPad)}>
<Checkbox
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
checked={allTargetSelected ? true : someTargetSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll}
aria-label="Select all rows on this page"
aria-label={
selectAllPages && !serverPaginated
? 'Select all rows'
: 'Select all rows on this page'
}
/>
</th>
)}

View File

@@ -8,76 +8,15 @@ import { Text } from '@astryxdesign/core/Text';
import { DT } from 'themes/dt/tokens';
// Free OSM Nominatim geocoder — replaces Google Places Autocomplete /
// react-geocode across the console. No API key required. Nominatim's usage
// policy caps the public endpoint at ~1 req/sec, so every caller here is
// debounced (500ms) and cancels stale in-flight predictions.
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
// Builds a Google-Places-shaped `address_components` array (each entry
// `{ long_name, short_name, types: [] }`) from Nominatim's flat `address`
// object, so existing call sites' `place.address_components.forEach(...)`
// type-switch parsing keeps working unchanged.
const buildAddressComponents = (addr = {}) => {
const components = [];
const push = (longName, types) => {
if (longName) components.push({ long_name: longName, short_name: longName, types });
};
push(addr.house_number, ['street_number']);
push(addr.road || addr.pedestrian, ['route']);
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
push(addr.city_district || addr.county, ['administrative_area_level_3']);
push(addr.city || addr.town || addr.village, ['locality']);
push(addr.state, ['administrative_area_level_1']);
push(addr.country, ['country']);
push(addr.postcode, ['postal_code']);
return components;
};
export const toPlace = (result) => standardizePlace(result);
// Shapes a Nominatim result close enough to a Google Places `place` object
// (formatted_address, geometry.location.lat()/lng(), address_components)
// that call sites need only mechanical edits, not rewrites.
export const toPlace = (result) => ({
formatted_address: result.display_name,
name: result.display_name?.split(',')[0] || '',
geometry: {
location: {
lat: () => parseFloat(result.lat),
lng: () => parseFloat(result.lon)
}
},
address_components: buildAddressComponents(result.address)
});
const buildParams = (query, bias) => {
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
if (bias?.lat && bias?.lng) {
const d = 0.5; // ~55km box around the operator's zone — soft bias, not a hard filter
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
}
return params;
};
// Standalone forward-geocode helper — replaces `Geocode.fromAddress(address)`
// for call sites that resolve a plain text address without a predictions
// dropdown (e.g. re-geocoding on submit). Returns a `toPlace`-shaped object,
// or null if nothing matched.
export async function geocodeAddress(address, { bias } = {}) {
if (!address) return null;
try {
const params = buildParams(address, bias);
params.set('limit', '1');
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const results = await res.json();
if (!results?.length) return null;
return toPlace(results[0]);
} catch (err) {
console.error('geocodeAddress error:', err);
return null;
}
export async function geocodeAddress(address, options = {}) {
return await serviceGeocodeAddress(address, options);
}
// Address search-as-you-type — replaces Google Places Autocomplete across the
// console.
//
@@ -121,9 +60,7 @@ const AddressAutocomplete = ({
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
const params = buildParams(query, bias);
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } })
.then((res) => (res.ok ? res.json() : []))
getAddressSuggestions(query, { bias, limit: 6 })
.then((results) => {
if (seq !== activeRef.current) return; // a newer keystroke won
setLoading(false);
@@ -135,10 +72,10 @@ const AddressAutocomplete = ({
setLoading(false);
setOptions([]);
});
}, 500);
}, 250);
useEffect(() => {
if (!inputValue) {
if (!inputValue || inputValue.length < 2) {
activeRef.current++; // invalidate any in-flight request
setOptions([]);
setLoading(false);
@@ -178,9 +115,10 @@ const AddressAutocomplete = ({
);
const pick = (option) => {
setInputValue(option.display_name || '');
onChange?.(option.display_name || '');
onPlaceSelected?.(toPlace(option));
const formatted = option.formatted_address || option.name || '';
setInputValue(formatted);
onChange?.(formatted);
onPlaceSelected?.(option);
setIsOpen(false);
};
@@ -212,8 +150,8 @@ const AddressAutocomplete = ({
{isOpen && options.length > 0 && (
<ul style={{ ...listStyle, listStyle: 'none', margin: 0, padding: 0 }} role="listbox" aria-label="Address suggestions">
{options.map((option) => (
<li key={option.place_id}>
{options.map((option, idx) => (
<li key={`${option.latitude}-${option.longitude}-${idx}`}>
<button
type="button"
onClick={() => pick(option)}
@@ -234,7 +172,7 @@ const AddressAutocomplete = ({
}}
>
<Text display="block" size="sm" maxLines={2}>
{option.display_name}
{option.formatted_address || option.name}
</Text>
</button>
</li>
@@ -243,6 +181,7 @@ const AddressAutocomplete = ({
)}
</div>
);
};
AddressAutocomplete.propTypes = {

View File

@@ -47,6 +47,7 @@ const LOGO_FILTER = {
/** Destinations that sit directly on the header bar. */
const NAV = [
{ label: 'Dispatch', path: '/doormile/dispatch' },
{ label: 'Bookings', path: '/doormile/bookings' },
{ label: 'Orders', path: '/doormile/orders' },
{ label: 'Deliveries', path: '/doormile/deliveries' },
{ label: 'Milers', path: '/doormile/milers' },

View File

@@ -146,7 +146,16 @@ export function ZoneProvider({ children }) {
}
// 1. Direct hub/location id match
//
// `servicinghubid` is queries.js's answer to "which hub OWNS this order",
// which is a different question from "where is it collected from" and the
// only one that stays answerable once the pickup is a customer's doorstep.
// It is consulted first, and only for staff sessions: a tenant's zones are
// its own locations, so a hub id compared against a tenantlocationid would
// match on a bare numeric collision and file the order under a zone that
// has nothing to do with it.
const itemHubId =
(!isTenantUser ? item.servicinghubid : null) ??
item.hubid ??
item.sourcehubid ??
item.applocationid ??

View File

@@ -4,11 +4,19 @@ import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* Reading bookings for the assistant.
*
* `GET /admin/bookings` has no date, status or tenant filter and caps `pagesize`
* at 1000 server-side, so every question is answered by draining pages and
* filtering here. A single `getBookings(1, 1000)` silently under-reports the
* moment an account passes 1000 lifetime bookings, which is why nothing in the
* assistant calls it.
* `GET /admin/bookings` caps `pagesize` at 100 server-side — NOT 1000, which is
* what this comment claimed for a long time and what `express-console-api.md`
* documented. A page is 100 rows, so MAX_PAGES x BULK_PAGESIZE is not the
* reachable total: 12 pages is 1200 rows, not 12,000.
*
* It IS tenant-scoped, and it does accept `?status=` — but as an exact,
* case-sensitive, single-value match against the stored capitalised enum
* (`Pending_Pickup`). The status GROUPS this console renders as tabs each cover
* several values in lowercase, so the server filter cannot express a tab and
* the grouping still happens here. There is no date filter.
*
* The list is ordered `bookingid DESC`, so page 1 is the newest page and a
* drain that stops early keeps the most recent bookings rather than the oldest.
*
* Every scan returns `{ rows, truncated, scanned, pagesFetched, total }` rather
* than a bare array, and **`truncated` is not optional to handle**. A count

54
src/lib/bookingDrops.js Normal file
View File

@@ -0,0 +1,54 @@
// ============================================================================
// One pickup, many drops.
//
// A customer-app booking is ONE pickup carrying N destinations — never N
// bookings. `GET /admin/bookings` reports how many each row has
// (`destinationcount`, `totalpackagecount`) without shipping the destination
// array, which is fetched only when a row is opened.
//
// Every helper here has to survive three shapes of row, because all three
// reach the Orders table at once:
//
// 1. The console running AHEAD of the backend that adds these fields, where
// `destinationcount` is undefined. Nothing may change for that row.
// 2. An ordinary single-drop booking, which is most of them.
// 3. A console-created booking, which has no bookingdestinations rows at all
// and reports 0 — distinct from 1, and rendered the same as case 1.
// ============================================================================
export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
/**
* How many drops one pickup carries, or 0 when the row cannot say.
*
* `Number(undefined) || 0` collapses the absent-field case to 0, which is what
* keeps a console deployed before the backend rendering exactly as it does
* today.
*/
export const dropCountOf = (row) => Number(row?.destinationcount) || 0;
/**
* Whether this pickup created more than one shipment.
*
* The single gate for every multi-drop behaviour on the Orders page: the
* summary line, and — more importantly — the refusal to render the booking's
* own `consignmentstatus` as the row's status. Deliberately `> 1`, never
* `>= 1`: "1 destination · 1 package" on every B2C row is noise on the common
* case, and a single-drop booking's consignment status IS the booking's.
*/
export const isMultiDrop = (row) => dropCountOf(row) > 1;
/**
* The collapsed row's summary — "3 destinations · 4 packages".
*
* Only meaningful when `isMultiDrop(row)`; callers gate on that.
*/
export const dropSummary = (row) => {
const drops = dropCountOf(row);
/* Packages fall back to the destination count rather than to zero. Every
destination carries at least one package, so "3 destinations · 0 packages"
describes a booking the data cannot actually produce — and would be the
reading if the count arrived without the sum. */
const packages = Number(row?.totalpackagecount) || drops;
return `${plural(drops, 'destination')} · ${plural(packages, 'package')}`;
};

120
src/lib/bulkOrderPayload.js Normal file
View File

@@ -0,0 +1,120 @@
// ==============================|| Bulk sheet row → booking payload ||============================== //
//
// The transform that turns one processed spreadsheet row into one
// POST /admin/expressbooking body. It lived inline inside MultipleOrders.jsx,
// where nothing could reach it: the only way to find out what a tenant's sheet
// actually produced was to upload it and read the orders afterwards.
//
// Pulled out so the real transform can be driven by a test against a real
// tenant file. The page imports it; nothing is reimplemented in either place,
// so a test that passes is a statement about what the page does, not about a
// copy of it that happens to agree today.
import { PICKUP_SOURCE, buildFlowFields, flowForDraft } from '@/lib/orderFlow';
export const BULK_SERVICE_OPTION = 'Normal';
export const DEFAULT_ITEM_CATEGORY = 'General';
/**
* The collection point for one row.
*
* `__pickup` travels on the ROW, not on the page. A single file can carry
* several origins — a real one carries three home kitchens across thirteen
* drops — and folding those onto one shared pickup silently re-addresses every
* row that did not come from it.
*/
export const rowPickupOf = (row, sharedPickup) => row?.__pickup || sharedPickup || null;
/**
* Parcels for one row.
*
* A parcel entry has NO quantity field: N items means N entries, which is how
* the rest of the app reads it back (`Quantity: b.parcels?.length`, see
* queries.js). This is why quantity has to expand here rather than travel as a
* number — a single entry for a five-item order reports as one parcel
* everywhere downstream, and the rider is told to collect one bag.
*/
export const buildParcels = (row) => {
const count = Math.max(1, Math.floor(Number(row?.quantity)) || 1);
const description = String(row?.description || '').trim() || 'Order';
const declaredvalue = Number(row?.totalcharge) || 0;
return Array.from({ length: count }, () => ({
itemcategory: DEFAULT_ITEM_CATEGORY,
itemdescription: description,
declaredvalue
}));
};
/**
* One processed row → one booking payload.
*
* `row` is a drop as MultipleOrders holds it after processing: sheet fields
* mapped to internal names, coordinates resolved, `distance`/`totalcharge`
* computed, and `__pickup` attached when the run collects from customers.
*/
export const buildBulkBookingPayload = ({
row,
sharedPickup,
tenantId,
tenantLocationId,
deliverytime,
notes = '',
anchors = []
}) => {
const origin = rowPickupOf(row, sharedPickup) || {};
const flow = flowForDraft({
// A row that resolved its own collection point is a customer pickup. One
// that fell back to the shared location is not — it really is being
// collected at the hub, and labelling it otherwise would put a doorstep
// pin on a facility. This is only the HINT: the coordinates below outrank
// it, so a sender address that turns out to be the tenant's own registered
// kitchen is booked as the hub run it is.
pickupSource: row?.__pickup ? PICKUP_SOURCE.CUSTOMER : PICKUP_SOURCE.HUB,
pickupLat: origin.latitude,
pickupLng: origin.longitude,
dropLat: row?.latitude,
dropLng: row?.longitude,
anchors
});
return {
tenantid: Number(tenantId),
pickupaddress: origin.address || '',
pickuppincode: String(origin.pincode || origin.postcode || ''),
pickupcity: origin.city || '',
pickuplatitude: Number(origin.latitude) || 0,
pickuplongitude: Number(origin.longitude) || 0,
customer_phone: row?.contactno != null ? String(row.contactno) : '',
customer_name: row?.firstname || '',
deliveryaddress: row?.address || '',
deliverypincode: row?.postcode != null ? String(row.postcode) : '',
deliverycity: row?.city || '',
deliverylatitude: Number(row?.latitude) || 0,
deliverylongitude: Number(row?.longitude) || 0,
deliverytime,
service_option: BULK_SERVICE_OPTION,
finalprice: Number(row?.totalcharge) || 0,
notes: notes || '',
...buildFlowFields({
flow: flow.flow,
// The facility we collect from, then the one we deliver to, then the
// selected location's own hub. A P2P row matches neither end, and falls
// through to the third — which is right: the run still belongs to the
// hub servicing it, even though no facility is a stop on the journey.
servicingHubId:
flow.originAnchor?.hubid ?? flow.destAnchor?.hubid ?? sharedPickup?.hubid ?? null,
// Same reasoning as the single-order form: the row records what kind of
// place it is collected from, rather than leaving every reader to
// re-derive it from two coordinates.
pickupAnchor: flow.originAnchor,
tenantLocationId
}),
parcels: buildParcels(row)
};
};
/** The whole run. Kept as a thin map so the page and the test agree on the
* ordering and count of what goes on the wire, not just on each row's shape. */
export const buildBulkBookingPayloads = ({ rows = [], ...shared }) =>
rows.map((row) => buildBulkBookingPayload({ row, ...shared }));

View File

@@ -0,0 +1,110 @@
// ==============================|| Customer-app bookings ||============================== //
//
// Which bookings came from the B2C customer app, as opposed to being created in
// this console.
//
// `bookingsource` is the only field that separates them, and its stored value is
// `"Customer_App"` vs `"CRM_Console"`. Note the second one: the outward API and
// routes were renamed from "CRM" to "express", but the stored column value was
// deliberately left alone — it is existing data, not a label. Matching on
// "express" here would find nothing.
//
// The predicate lives in lib/ rather than inline in the page so the rule is
// stated once and can be tested on its own.
export const BOOKING_SOURCE = Object.freeze({
CUSTOMER_APP: 'Customer_App',
CONSOLE: 'CRM_Console',
});
/**
* True when a booking was made by a customer in the app.
*
* Matched case-insensitively and trimmed, because this value is compared against
* a raw database column rather than a validated enum, and a stray space would
* silently empty the page.
*
* A booking with NO bookingsource is not counted. The column defaults to
* `Customer_App` at the database level, so a blank is almost certainly a console
* booking written before the column existed — and quietly filing unknown rows
* under "customer app" would overstate B2C volume, which is a number people
* make decisions on.
*/
export const isCustomerAppBooking = (booking) =>
String(booking?.bookingsource || '').trim().toLowerCase() ===
BOOKING_SOURCE.CUSTOMER_APP.toLowerCase();
/** Every customer-app booking in a list, newest first. */
export const customerAppBookings = (bookings) =>
(bookings || [])
.filter(isCustomerAppBooking)
.sort((a, b) => (Number(b?.bookingid) || 0) - (Number(a?.bookingid) || 0));
/**
* The price a booking was quoted.
*
* This is the same row `GET /customer/bookings/{id}/price` returns — that
* endpoint reads the booking's newest `BookingServiceOption`, which is already
* preloaded onto every admin booking row. So the console needs no separate call
* for it, and there is no state where the list and the quote disagree.
*/
export const serviceOptionOf = (booking) => {
const options = booking?.serviceoptions || [];
if (options.length === 0) return null;
// Newest wins, matching the backend's `ORDER BY createdat DESC LIMIT 1`.
return [...options].sort(
(a, b) => new Date(b?.createdat || 0) - new Date(a?.createdat || 0)
)[0];
};
export const quotedPrice = (booking) => Number(serviceOptionOf(booking)?.estimatedprice) || 0;
export const serviceType = (booking) => serviceOptionOf(booking)?.servicetype || '';
/**
* The tabs on the page: Created, Rider Assigned, Delivered, Cancelled, and All.
*/
export const BOOKING_TABS = [
{ value: 'all', label: 'All' },
{ value: 'created', label: 'Created' },
{ value: 'assigned', label: 'Rider Assigned' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'cancelled', label: 'Cancelled' },
];
export const isBookingInTab = (booking, tabValue) => {
const status = String(booking?.status || '').trim().toLowerCase();
const consignmentStatus = String(booking?.consignmentstatus || booking?.consignment_status || '').trim().toLowerCase();
const hasRider = Boolean(booking?.assignedmileruserid || booking?.mileruserid);
if (tabValue === 'all') return true;
if (tabValue === 'cancelled') {
return status === 'cancelled' || consignmentStatus === 'cancelled';
}
if (tabValue === 'delivered') {
return status === 'delivered' || consignmentStatus === 'delivered';
}
if (tabValue === 'created') {
if (status === 'cancelled' || consignmentStatus === 'cancelled' || status === 'delivered' || consignmentStatus === 'delivered') {
return false;
}
// Unassigned created / pending pickup orders
return (status === 'created' || status === 'pending_pickup') && !hasRider;
}
if (tabValue === 'assigned') {
if (status === 'cancelled' || consignmentStatus === 'cancelled' || status === 'delivered' || consignmentStatus === 'delivered') {
return false;
}
return hasRider || status === 'miler_assigned' || status === 'pickup_scheduled' || status === 'picked_up' || status === 'converted_to_consignment' || status === 'out_for_delivery' || status === 'inwarded_at_hub';
}
return true;
};
/** Rows for one tab. */
export const bookingsForTab = (bookings, tabValue) => {
return (bookings || []).filter((b) => isBookingInTab(b, tabValue));
};

View File

@@ -259,6 +259,27 @@ export const useHubs = (options) =>
export const useHub = (id, options) =>
useQuery({ queryKey: KEYS.hub(id), queryFn: () => api.getHub(id), enabled: !!id, ...options });
/**
* The facility list used to decide which end of a booking is a hub.
*
* Lives here rather than in each page so every screen classifies against the
* SAME facilities. A page that built its own list from hubs alone would call
* every client's kitchen a customer pickup, and the same order would then carry
* different labels on Orders and on Dispatch.
*
* `tenantIds` is sorted into the cache key so the same set of tenants in a
* different order is one cache entry, not two.
*/
export const useFlowAnchors = (tenantIds = [], options) => {
const ids = [...new Set((tenantIds || []).filter((id) => id != null).map(String))].sort();
return useQuery({
queryKey: ['doormile', 'flowAnchors', ids.join(',')],
queryFn: () => api.fetchFlowAnchors(ids),
staleTime: 5 * 60_000,
...options,
});
};
export const useCreateHub = () =>
useDoormileMutation({ mutationFn: api.createHub, invalidates: [KEYS.hubs], successMessage: 'Hub created' });

352
src/lib/geocodingService.js Normal file
View File

@@ -0,0 +1,352 @@
/**
* Enterprise Geocoding & Address Autocomplete Service
*
* Supports multi-provider hierarchy:
* 1. Ola Maps Places Autocomplete (if VITE_OLA_MAPS_API_KEY is configured)
* 2. Google Places API (if VITE_GOOGLE_MAPS_API_KEY is configured)
* 3. Photon (Komoot OSM - high-speed, typo-tolerant, sub-100ms autocomplete)
* 4. Nominatim (OpenStreetMap standard geocoder with country lock)
*/
const getEnvVar = (key) => {
try {
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[key]) {
return import.meta.env[key];
}
} catch {}
try {
if (typeof process !== 'undefined' && process.env && process.env[key]) {
return process.env[key];
}
} catch {}
return '';
};
const OLA_MAPS_KEY = getEnvVar('VITE_OLA_MAPS_API_KEY');
const PHOTON_API_URL = 'https://photon.komoot.io/api';
const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse';
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse';
/**
* Standardize place result into unified format for Doormile order forms
*/
export const standardizePlace = ({
formatted_address = '',
name = '',
suburb = '',
city = '',
state = '',
postcode = '',
latitude = null,
longitude = null,
provider = 'unknown',
raw = null
}) => {
const latNum = latitude != null ? Number(latitude) : null;
const lngNum = longitude != null ? Number(longitude) : null;
return {
formatted_address: formatted_address || name,
name: name || formatted_address.split(',')[0] || '',
suburb: suburb || '',
city: city || '',
state: state || '',
postcode: postcode || '',
latitude: Number.isFinite(latNum) ? latNum : null,
longitude: Number.isFinite(lngNum) ? lngNum : null,
provider,
raw,
// Google Places / Legacy compatibility interface
geometry: {
location: {
lat: () => (Number.isFinite(latNum) ? latNum : 0),
lng: () => (Number.isFinite(lngNum) ? lngNum : 0)
}
},
address_components: [
...(suburb ? [{ long_name: suburb, short_name: suburb, types: ['sublocality_level_1', 'sublocality'] }] : []),
...(city ? [{ long_name: city, short_name: city, types: ['locality'] }] : []),
...(state ? [{ long_name: state, short_name: state, types: ['administrative_area_level_1'] }] : []),
...(postcode ? [{ long_name: postcode, short_name: postcode, types: ['postal_code'] }] : [])
]
};
};
/**
* 1. Photon (Komoot OSM) Search - Fast, typo-tolerant, ideal for real-time keystroke suggestions
*/
async function searchPhoton(query, { bias, limit = 6 } = {}) {
try {
const params = new URLSearchParams({
q: query,
limit: String(limit),
lang: 'en'
});
if (bias?.lat && bias?.lng) {
params.set('lat', String(bias.lat));
params.set('lon', String(bias.lng));
}
const res = await fetch(`${PHOTON_API_URL}?${params.toString()}`, {
headers: { Accept: 'application/json' }
});
if (!res.ok) return [];
const data = await res.json();
if (!data?.features || !Array.isArray(data.features)) return [];
return data.features.map((item) => {
const p = item.properties || {};
const [lon, lat] = item.geometry?.coordinates || [null, null];
const parts = [
p.name,
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
p.district || p.suburb || p.neighbourhood || p.locality,
p.city,
p.state,
p.postcode,
p.country
].filter(Boolean);
// Remove consecutive duplicates in address string
const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx);
const formatted = uniqueParts.join(', ');
return standardizePlace({
formatted_address: formatted || p.name || '',
name: p.name || formatted.split(',')[0],
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
city: p.city || p.county || '',
state: p.state || '',
postcode: p.postcode || '',
latitude: lat,
longitude: lon,
provider: 'photon',
raw: item
});
});
} catch (err) {
console.warn('[Geocoding] Photon search failed:', err);
return [];
}
}
/**
* 2. Nominatim Search - Fallback with countrycodes=in & addressdetails
*/
async function searchNominatim(query, { bias, country = 'in', limit = 5 } = {}) {
try {
const params = new URLSearchParams({
q: query,
format: 'json',
addressdetails: '1',
limit: String(limit)
});
if (country) {
params.set('countrycodes', country.toLowerCase());
}
if (bias?.lat && bias?.lng) {
const d = 0.5; // ~55km bounding box
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
}
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: {
Accept: 'application/json',
'Accept-Language': 'en-GB,en;q=0.9',
'User-Agent': 'DoormileConsole/1.0'
}
});
if (!res.ok) return [];
const results = await res.json();
if (!Array.isArray(results)) return [];
return results.map((r) => {
const addr = r.address || {};
const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '';
const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || '';
const state = addr.state || '';
const postcode = addr.postcode || '';
return standardizePlace({
formatted_address: r.display_name || '',
name: r.display_name?.split(',')[0] || '',
suburb,
city,
state,
postcode,
latitude: r.lat,
longitude: r.lon,
provider: 'nominatim',
raw: r
});
});
} catch (err) {
console.warn('[Geocoding] Nominatim search failed:', err);
return [];
}
}
/**
* 3. Ola Maps Places Autocomplete (if key configured)
*/
async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
if (!OLA_MAPS_KEY) return [];
try {
const params = new URLSearchParams({
input: query,
api_key: OLA_MAPS_KEY
});
if (bias?.lat && bias?.lng) {
params.set('location', `${bias.lat},${bias.lng}`);
params.set('radius', '50000');
}
const res = await fetch(`https://api.olamaps.io/places/v1/autocomplete?${params.toString()}`);
if (!res.ok) return [];
const data = await res.json();
if (!data?.predictions || !Array.isArray(data.predictions)) return [];
return data.predictions.slice(0, limit).map((p) => {
const lat = p.geometry?.location?.lat;
const lng = p.geometry?.location?.lng;
return standardizePlace({
formatted_address: p.description || '',
name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '',
suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '',
city: '',
state: '',
postcode: '',
latitude: lat,
longitude: lng,
provider: 'olamaps',
raw: p
});
});
} catch (err) {
console.warn('[Geocoding] Ola Maps autocomplete failed:', err);
return [];
}
}
/**
* Primary Address Autocomplete Dispatcher
* Queries fastest/best provider first, then falls back seamlessly.
*/
export async function getAddressSuggestions(query, options = {}) {
const cleanQuery = (query || '').trim();
if (!cleanQuery || cleanQuery.length < 2) return [];
// 1. Try Ola Maps if API key is present
if (OLA_MAPS_KEY) {
const olaResults = await searchOlaMaps(cleanQuery, options);
if (olaResults.length > 0) return olaResults;
}
// 2. High-speed Photon OSM autocomplete (sub-100ms, typo tolerant)
const photonResults = await searchPhoton(cleanQuery, options);
if (photonResults.length > 0) return photonResults;
// 3. Fallback to scoped Nominatim
const nominatimResults = await searchNominatim(cleanQuery, options);
return nominatimResults;
}
/**
* Reverse Geocode: Convert Coordinates to structured address
*/
export async function reverseGeocode(lat, lng) {
if (lat == null || lng == null) return null;
// 1. Try Photon reverse first
try {
const res = await fetch(`${PHOTON_REVERSE_URL}?lat=${lat}&lon=${lng}`, {
headers: { Accept: 'application/json' }
});
if (res.ok) {
const data = await res.json();
if (data?.features?.length > 0) {
const item = data.features[0];
const p = item.properties || {};
const parts = [
p.name,
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
p.district || p.suburb || p.neighbourhood || p.locality,
p.city,
p.state,
p.postcode,
p.country
].filter(Boolean);
const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', ');
return standardizePlace({
formatted_address: formatted,
name: p.name || formatted.split(',')[0],
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
city: p.city || p.county || '',
state: p.state || '',
postcode: p.postcode || '',
latitude: lat,
longitude: lng,
provider: 'photon_reverse'
});
}
}
} catch (e) {
console.warn('[Geocoding] Photon reverse failed, trying Nominatim:', e);
}
// 2. Fallback to Nominatim reverse
try {
const res = await fetch(
`${NOMINATIM_REVERSE_URL}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`,
{
headers: {
Accept: 'application/json',
'Accept-Language': 'en-GB,en;q=0.9',
'User-Agent': 'DoormileConsole/1.0'
}
}
);
if (res.ok) {
const r = await res.json();
const addr = r.address || {};
return standardizePlace({
formatted_address: r.display_name || '',
name: r.display_name?.split(',')[0] || '',
suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '',
city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '',
state: addr.state || '',
postcode: addr.postcode || '',
latitude: lat,
longitude: lng,
provider: 'nominatim_reverse'
});
}
} catch (err) {
console.error('[Geocoding] Reverse geocode failed completely:', err);
}
return standardizePlace({
formatted_address: `Location (${Number(lat).toFixed(5)}, ${Number(lng).toFixed(5)})`,
name: 'Selected Pin',
latitude: lat,
longitude: lng,
provider: 'coordinates_only'
});
}
/**
* Direct forward geocode for single address string (used by bulk uploads / fallback)
*/
export async function geocodeAddress(address, options = {}) {
const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 });
return suggestions.length > 0 ? suggestions[0] : null;
}

62
src/lib/hubForm.js Normal file
View File

@@ -0,0 +1,62 @@
// ==============================|| Hub (base) form validation ||============================== //
//
// A hub in this console is a BASE to a rider. The backend hands six fields to
// the rider app as the place to go — id, name, address, pincode, latitude,
// longitude — and the app uses them to title the stop, show the gate address,
// and drive Navigate.
//
// Five of six is not "mostly there". Without coordinates Navigate does nothing;
// without an address the rider has nothing to read when they arrive. Only the
// name and the city id were ever validated here, which is how a live base ended
// up in the network with no address and no pin on the map.
//
// Lives in lib/ rather than inside the page so it can be tested as the pure
// decision it is, in the same shape as orderFlow and batchBucket.
/** A coordinate that is present, numeric, in range, and not the 0,0 that means
* "never pinned". 0,0 is in the Gulf of Guinea; no Doormile base is there, so
* it is always an unset value rather than a location. */
const coordinateError = (value, { min, max, axis }) => {
if (value === '' || value === null || value === undefined) return 'Navigate cannot work without a pin';
const n = Number(value);
if (Number.isNaN(n)) return 'Enter a number';
if (n === 0) return 'Navigate cannot work without a pin';
if (n < min || n > max) return `${axis} must be between ${min} and ${max}`;
return null;
};
/**
* Validate the hub create/edit form.
*
* Returns `{ errors, values }`. `errors` is keyed by field name and is empty
* when the form is good; `values` carries the coordinates already coerced to
* numbers, so the caller does not re-parse what was just validated.
*/
export const validateHubForm = (form = {}) => {
const errors = {};
if (!String(form.hubname || '').trim()) errors.hubname = 'Give the hub a name';
if (form.applocationid === '' || form.applocationid === null || form.applocationid === undefined
|| Number.isNaN(Number(form.applocationid))) {
errors.applocationid = 'Enter the numeric city id';
}
if (!String(form.address || '').trim()) errors.address = 'A rider needs an address to find the gate';
if (!String(form.pincode || '').trim()) errors.pincode = 'Needed to tell two bases in one city apart';
const latError = coordinateError(form.latitude, { min: -90, max: 90, axis: 'Latitude' });
if (latError) errors.latitude = latError;
const lngError = coordinateError(form.longitude, { min: -180, max: 180, axis: 'Longitude' });
if (lngError) errors.longitude = lngError;
return {
errors,
isValid: Object.keys(errors).length === 0,
values: {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
applocationid: Number(form.applocationid)
}
};
};

567
src/lib/orderFlow.js Normal file
View File

@@ -0,0 +1,567 @@
// ==============================|| Order flow classification ||============================== //
//
// A booking is two coordinate pairs and nothing else — `pickup*` and
// `delivery*`. The backend has never carried a field saying which END is a
// Doormile facility, so every consumer in the console silently assumed the
// pickup was always the hub. That assumption is baked into three places that
// break the moment it stops holding:
//
// • queries.js used the TENANT NAME as the pickup point's identity, so every
// order of a tenant claimed the same collection point wherever it actually
// was.
// • Dispatch's kitchen map grouped by that name and took its pin coordinates
// from whichever order happened to be first in the array.
// • buildTripPoints drew a rider's polyline as [one pickup, drop, drop, …].
//
// This module is the single answer to "which end is the facility?", so those
// consumers agree with each other by construction rather than by three
// independent guesses.
//
// TWO SOURCES, in priority order:
//
// 1. An EXPLICIT `ordertype` on the booking. The console now sends this on
// create (see buildFlowFields below). Once the backend persists and
// returns it, it is authoritative and no geometry is consulted.
// 2. DERIVATION by proximity to a known facility. This is what makes the
// whole existing order history classify correctly today, with no backend
// change and no migration — an order created before this module existed
// still lands in the right bucket because its pickup coordinates still
// sit on top of the hub they were copied from.
//
// When neither resolves — no anchors loaded, or coordinates missing — the
// answer is FORWARD. That is deliberately the pre-existing behaviour: an
// unclassifiable order must look exactly like it did before this file was
// added, never like a new flow the operator did not create.
/** Metres. A hub pickup copies the facility's own coordinates onto the
* booking, so the true distance is 0; the radius absorbs the operator who
* re-pinned the gate instead of the unit, and a geocoder that rounds. Wide
* enough for a compound, tight enough that the customer across the road is
* still a customer. */
export const ANCHOR_RADIUS_M = 200;
export const PICKUP_SOURCE = Object.freeze({ HUB: 'hub', CUSTOMER: 'customer' });
export const DROP_TARGET = Object.freeze({ HUB: 'hub', CUSTOMER: 'customer' });
/**
* The backend's `pickup_source_type` vocabulary — what KIND of place a parcel
* is collected from. Four values where this module has two, and the difference
* is load-bearing rather than cosmetic.
*
* `PICKUP_SOURCE` above is a geometry question: is the collection point a
* facility, or a doorstep? A client's own kitchen answers HUB to that, because
* it is a facility with coordinates on file. But to a rider it is a merchant,
* not a base — different signage, different person to ask for, different screen
* heading. Collapsing the two is exactly what left the rider app titling every
* logistics pickup with the rider's own base name, so a collection at a shop
* and one at a house looked identical.
*
* So: the anchor's `kind` decides this, not `pickupSource`.
*
* These strings are the wire contract and must match the backend's constants
* exactly. The rider app renders `hub` as "Base"; that is its wording, not ours,
* and it never travels back up the wire.
*/
export const PICKUP_SOURCE_TYPE = Object.freeze({
HUB: 'hub',
CUSTOMER: 'customer',
MERCHANT: 'merchant',
STORE: 'store'
});
const PICKUP_SOURCE_TYPE_VALUES = Object.values(PICKUP_SOURCE_TYPE);
/** Operator-facing wording for a source type. */
export const PICKUP_SOURCE_TYPE_LABEL = Object.freeze({
[PICKUP_SOURCE_TYPE.HUB]: 'Base',
[PICKUP_SOURCE_TYPE.CUSTOMER]: 'Customer door',
[PICKUP_SOURCE_TYPE.MERCHANT]: 'Client site',
[PICKUP_SOURCE_TYPE.STORE]: 'Store'
});
/** An unrecognised type is shown as itself rather than dropped — the backend
* may add values, and a blank chip is worse than an unfamiliar word. */
export const pickupSourceTypeLabel = (type) =>
PICKUP_SOURCE_TYPE_LABEL[type] || type || PICKUP_SOURCE_TYPE_LABEL[PICKUP_SOURCE_TYPE.CUSTOMER];
const isKnownSourceType = (value) => PICKUP_SOURCE_TYPE_VALUES.includes(value);
/**
* Which of the four the matched pickup anchor is. No anchor means the rider is
* going to somebody's door, and `customer` is the honest answer for that — a
* value, never an omission, because "no id because it is a front door" has to be
* distinguishable from "no id because nobody filled it in".
*/
export const sourceTypeForAnchor = (anchor) => {
if (!anchor) return PICKUP_SOURCE_TYPE.CUSTOMER;
if (anchor.kind === 'hub') return PICKUP_SOURCE_TYPE.HUB;
if (anchor.kind === 'tenantlocation') return PICKUP_SOURCE_TYPE.MERCHANT;
return PICKUP_SOURCE_TYPE.CUSTOMER;
};
export const FLOW = Object.freeze({
/** hub → customer. The classic outbound run, and everything the console did
* before this module. */
FORWARD: 'Forward',
/** customer → hub. "Customer pickup": the rider travels out to the
* customer's own door, collects there, and brings the parcel inbound.
* Returns, merchant collections, RTO. */
REVERSE: 'Reverse',
/** customer → customer. Hyperlocal point-to-point; no facility is a physical
* stop on the trip, the hub only OWNS the order for zone and reporting. */
P2P: 'P2P',
/** hub → hub. Trunk movement between facilities. Tripsheets already model
* this properly; it is named here so a booking that happens to look like it
* is not silently mislabelled as something else. */
LINEHAUL: 'Linehaul'
});
const FLOW_BY_ENDPOINTS = {
[`${PICKUP_SOURCE.HUB}|${DROP_TARGET.CUSTOMER}`]: FLOW.FORWARD,
[`${PICKUP_SOURCE.CUSTOMER}|${DROP_TARGET.HUB}`]: FLOW.REVERSE,
[`${PICKUP_SOURCE.CUSTOMER}|${DROP_TARGET.CUSTOMER}`]: FLOW.P2P,
[`${PICKUP_SOURCE.HUB}|${DROP_TARGET.HUB}`]: FLOW.LINEHAUL
};
const ENDPOINTS_BY_FLOW = {
[FLOW.FORWARD]: { pickupSource: PICKUP_SOURCE.HUB, dropTarget: DROP_TARGET.CUSTOMER },
[FLOW.REVERSE]: { pickupSource: PICKUP_SOURCE.CUSTOMER, dropTarget: DROP_TARGET.HUB },
[FLOW.P2P]: { pickupSource: PICKUP_SOURCE.CUSTOMER, dropTarget: DROP_TARGET.CUSTOMER },
[FLOW.LINEHAUL]: { pickupSource: PICKUP_SOURCE.HUB, dropTarget: DROP_TARGET.HUB }
};
/** Operator-facing wording. `Reverse` is the internal name; "Customer Pickup"
* is what the people running the board actually call it, so that is what the
* chip says. */
export const FLOW_LABEL = Object.freeze({
[FLOW.FORWARD]: 'Hub Pickup',
[FLOW.REVERSE]: 'Customer Pickup',
[FLOW.P2P]: 'Point to Point',
[FLOW.LINEHAUL]: 'Linehaul'
});
export const flowLabel = (flow) => FLOW_LABEL[flow] || FLOW_LABEL[FLOW.FORWARD];
/** True when the rider must travel out to a non-facility address to collect —
* i.e. the trip carries a first-mile leg that a hub run does not. Both the
* route builder and the deadhead pricing key off this, not off the flow name,
* so adding a fifth flow later cannot leave one of them behind. */
export const hasCustomerPickup = (flow) =>
ENDPOINTS_BY_FLOW[flow]?.pickupSource === PICKUP_SOURCE.CUSTOMER;
/** True when the parcel ends at a facility rather than a doorstep. */
export const hasHubDrop = (flow) => ENDPOINTS_BY_FLOW[flow]?.dropTarget === DROP_TARGET.HUB;
const isValidFlow = (value) => Object.values(FLOW).includes(value);
/** Accepts the loose casing a backend or a spreadsheet may send
* ("reverse", "REVERSE", "customer_pickup") and returns a canonical FLOW, or
* null when the value means nothing to us. Never throws — an unrecognised
* string must fall through to derivation, not blow up a page render. */
export const normalizeFlow = (value) => {
const raw = String(value ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
if (!raw) return null;
const direct = Object.values(FLOW).find((f) => f.toLowerCase() === raw);
if (direct) return direct;
if (raw === 'customer_pickup' || raw === 'reverse_pickup' || raw === 'pickup') return FLOW.REVERSE;
if (raw === 'hub_pickup' || raw === 'delivery' || raw === 'outbound') return FLOW.FORWARD;
if (raw === 'point_to_point' || raw === 'hyperlocal' || raw === 'p2p') return FLOW.P2P;
if (raw === 'line_haul' || raw === 'trunk') return FLOW.LINEHAUL;
return null;
};
const toNum = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : NaN;
};
const EARTH_RADIUS_M = 6371000;
const distanceMetres = (lat1, lon1, lat2, lon2) => {
const a1 = toNum(lat1);
const o1 = toNum(lon1);
const a2 = toNum(lat2);
const o2 = toNum(lon2);
if ([a1, o1, a2, o2].some(Number.isNaN)) return Infinity;
// A 0,0 coordinate is the backend's "unset", not the Gulf of Guinea. Treating
// it as a real point would put every unset pickup ~7000 km from every hub,
// which is harmless for the radius test but reads as a real measurement to
// anything that later logs this distance.
if ((a1 === 0 && o1 === 0) || (a2 === 0 && o2 === 0)) return Infinity;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(a2 - a1);
const dLon = toRad(o2 - o1);
const h =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a1)) * Math.cos(toRad(a2)) * Math.sin(dLon / 2) ** 2;
return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
};
export const metresBetween = distanceMetres;
/** Decimal places used to bucket a doorstep collection into a grouping key.
* Four is ~11 m at this latitude — tight enough that two neighbouring houses
* stay separate, loose enough that one address geocoded twice lands in one
* bucket. Two collections at the SAME door must share a key: a real sheet
* (three home kitchens, thirteen drops) has six rows leaving one kitchen, and
* keying those per-booking would draw six pins stacked on one another. */
const COLLECTION_POINT_PRECISION = 4;
/** Can this coordinate pair be reasoned about at all? Missing, non-numeric and
* the backend's 0,0 "unset" all answer no. An endpoint that answers no can
* never be *proved* to be a customer's door — the absence of a hub match
* there means nothing — so classification must not read anything into it. */
const isUsablePoint = (lat, lon) => {
const a = toNum(lat);
const o = toNum(lon);
if (Number.isNaN(a) || Number.isNaN(o)) return false;
return !(a === 0 && o === 0);
};
/**
* Fold the two facility lists the console knows about into one anchor set.
*
* `hubs` are Doormile's own sorting centres and delivery hubs (GET
* /admin/hubs). `tenantLocations` are a client's own origins — the kitchen,
* the depot, the branch (GET /admin/tenants/:id/locations). Both count as "a
* facility, not a customer's door", and an order picked up at either one is a
* hub pickup as far as the board is concerned.
*
* Anchors without usable coordinates are dropped rather than kept with NaN:
* a hub nobody geocoded cannot confirm OR deny anything, and keeping it would
* make every distance comparison against it Infinity anyway.
*/
/** How far a facility can sit from a hub and still be served by it. 35 km is
* not arbitrary — it is the radius ZoneContext already uses to decide whether
* an order belongs to a hub's zone. Deriving a different number here would let
* an order be stamped with one hub and then filtered into another. */
export const SERVICING_HUB_RADIUS_M = 35000;
/** The hub that services a point, or null. Nearest wins, for the same reason
* matchAnchor picks nearest: array order is not a fact about geography. */
const nearestHubId = (lat, lon, hubAnchors) => {
let bestId = null;
let bestDist = Infinity;
(hubAnchors || []).forEach((h) => {
if (h.hubid == null) return;
const d = distanceMetres(lat, lon, h.lat, h.lon);
if (d <= SERVICING_HUB_RADIUS_M && d < bestDist) {
bestId = h.hubid;
bestDist = d;
}
});
return bestId;
};
export const buildAnchors = ({ hubs = [], tenantLocations = [] } = {}) => {
const anchors = [];
const hubAnchors = [];
(hubs || []).forEach((h) => {
const lat = toNum(h?.latitude ?? h?.lat);
const lon = toNum(h?.longitude ?? h?.lng ?? h?.lon);
if (Number.isNaN(lat) || Number.isNaN(lon)) return;
const id = h?.hubid ?? h?.id;
const anchor = {
key: `hub:${id}`,
hubid: id ?? null,
tenantid: null,
name: h?.hubname || h?.name || h?.address || `Hub #${id}`,
address: h?.address || '',
lat,
lon,
kind: 'hub'
};
anchors.push(anchor);
hubAnchors.push(anchor);
});
(tenantLocations || []).forEach((l) => {
const lat = toNum(l?.latitude ?? l?.lat);
const lon = toNum(l?.longitude ?? l?.lng ?? l?.lon);
if (Number.isNaN(lat) || Number.isNaN(lon)) return;
const id = l?.tenantlocationid ?? l?.locationid ?? l?.id;
anchors.push({
key: `loc:${id}`,
// A tenant location record carries NO hubid — the API simply has no such
// field on it. Left as null, every order originating at a client's own
// kitchen went to the backend with no servicing hub at all, which is the
// whole reason zone attribution still had to fall back to matching hub
// names against concatenated address text. Since a location is physically
// served by whichever hub covers its city, the nearest one within
// SERVICING_HUB_RADIUS_M is that answer.
hubid: l?.hubid ?? nearestHubId(lat, lon, hubAnchors),
tenantid: l?.tenantid ?? null,
name: l?.locationname || l?.name || l?.address || `Location #${id}`,
address: l?.address || '',
lat,
lon,
kind: 'tenantlocation'
});
});
return anchors;
};
/**
* Nearest anchor within ANCHOR_RADIUS_M, or null.
*
* Nearest rather than first-within-radius: two facilities can share a compound
* (a sorting centre and a client depot at the same address is normal), and
* picking whichever was earlier in the array would name the pin inconsistently
* depending on the order /admin/hubs happened to return.
*/
export const matchAnchor = (lat, lon, anchors = [], radiusM = ANCHOR_RADIUS_M) => {
let best = null;
let bestDist = Infinity;
(anchors || []).forEach((a) => {
const d = distanceMetres(lat, lon, a.lat, a.lon);
if (d <= radiusM && d < bestDist) {
best = a;
bestDist = d;
}
});
return best;
};
const pickupCoords = (b) => ({
lat: b?.pickuplatitude ?? b?.pickuplat ?? b?.pickup_lat,
lon: b?.pickuplongitude ?? b?.pickuplong ?? b?.pickup_lon
});
const dropCoords = (b) => ({
lat: b?.deliverylatitude ?? b?.droplat ?? b?.deliverylat ?? b?.delivery_lat,
lon: b?.deliverylongitude ?? b?.droplon ?? b?.deliverylong ?? b?.delivery_lon
});
/**
* Classify one booking.
*
* Returns, always, a complete shape — callers render from it directly and must
* never have to null-check a field into a blank badge.
*
* flow one of FLOW
* pickupSource PICKUP_SOURCE — which kind of place the parcel leaves
* dropTarget DROP_TARGET — which kind of place it arrives at
* originAnchor the matched facility at the pickup end, or null
* destAnchor the matched facility at the drop end, or null
* pickupPointKey STABLE grouping key for the collection point. This is the
* field Dispatch groups its map pins by. For a facility it
* is the anchor key, so every order off that hub shares one
* pin; for a customer pickup it is per-booking, because two
* customer doors are two different places and merging them
* is exactly the bug this replaces.
* pickupPointName what to show for that point
* servicingHubId the hub that OWNS the order for zone filtering and
* reporting — deliberately NOT "the hub we pick up from",
* because on a P2P run there is no such hub and the order
* still belongs to a zone.
* derived true when geometry decided it, false when the booking
* carried an explicit ordertype. Useful when an operator
* asks why a row is labelled the way it is.
*/
export const classifyBooking = (booking, anchors = []) => {
const b = booking || {};
const p = pickupCoords(b);
const d = dropCoords(b);
const originAnchor = matchAnchor(p.lat, p.lon, anchors);
const destAnchor = matchAnchor(d.lat, d.lon, anchors);
const explicit = normalizeFlow(b.ordertype ?? b.order_type ?? b.flowtype ?? b.flow_type);
// The backend now stores what kind of place a booking is collected from, and
// returns it on the row. When it is there it is the answer — it was recorded
// at creation by whoever actually knew, rather than re-inferred from two
// coordinates every time a page loads. Geometry stays as the classifier for
// the whole order history created before the column existed.
const storedSourceType = isKnownSourceType(b.pickup_source_type ?? b.pickupsourcetype)
? b.pickup_source_type ?? b.pickupsourcetype
: null;
const storedPickupHubId = b.pickuphubid ?? b.pickup_hub_id ?? null;
let flow;
let pickupSource;
let dropTarget;
if (explicit) {
flow = explicit;
({ pickupSource, dropTarget } = ENDPOINTS_BY_FLOW[explicit]);
} else if (!anchors?.length || !isUsablePoint(p.lat, p.lon)) {
// Either no facility list loaded, or the booking has no usable pickup
// coordinates. Geometry can prove nothing in either case, so hold the
// pre-existing behaviour rather than inventing a flow from silence — a
// pickup that failed to geocode is a data problem, not a customer pickup,
// and labelling it as one would put a phantom collection pin on the map.
flow = FLOW.FORWARD;
({ pickupSource, dropTarget } = ENDPOINTS_BY_FLOW[FLOW.FORWARD]);
} else {
pickupSource = originAnchor ? PICKUP_SOURCE.HUB : PICKUP_SOURCE.CUSTOMER;
// Same reasoning at the drop end: an un-geocoded drop cannot be shown to
// be a facility, and the overwhelmingly common case is a customer.
dropTarget = destAnchor && isUsablePoint(d.lat, d.lon) ? DROP_TARGET.HUB : DROP_TARGET.CUSTOMER;
flow = FLOW_BY_ENDPOINTS[`${pickupSource}|${dropTarget}`];
}
const bookingKey = b.bookingid ?? b.id ?? b.orderheaderid ?? b.bookingno ?? 'unknown';
// Stored value wins; otherwise the matched anchor's kind decides, which is the
// same judgement the create form makes when it stamps the field.
const pickupSourceType = storedSourceType ?? sourceTypeForAnchor(originAnchor);
const isHubPickup = pickupSource === PICKUP_SOURCE.HUB;
// A facility groups by its anchor. A doorstep groups by WHERE IT IS, so two
// bookings collected at one door are one visit and one pin, while two
// different doors stay apart. The booking id is only the last resort, for a
// collection whose coordinates never resolved — those must not merge with
// each other, because nothing has shown them to be the same place.
const pickupPointKey =
isHubPickup && originAnchor
? originAnchor.key
: isUsablePoint(p.lat, p.lon)
? `cust:${Number(p.lat).toFixed(COLLECTION_POINT_PRECISION)},${Number(p.lon).toFixed(
COLLECTION_POINT_PRECISION
)}`
: `cust:${bookingKey}`;
const pickupPointName = isHubPickup
? originAnchor?.name || b.pickupaddress || 'Hub'
: b.pickupcontactname || b.pickup_name || b.pickupaddress || 'Customer Pickup';
// Explicit ids first, matched anchor second, destination facility third. The
// last one matters for a customer pickup that is inbound to a hub: nothing
// on the pickup end names a facility, but the order plainly belongs to the
// hub it is being brought to.
const servicingHubId =
b.sourcehubid ??
b.hubid ??
(originAnchor?.kind === 'hub' ? originAnchor.hubid : null) ??
originAnchor?.hubid ??
(destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ??
destAnchor?.hubid ??
null;
return {
flow,
flowLabel: flowLabel(flow),
pickupSource,
/** One of PICKUP_SOURCE_TYPE — the backend's four-value vocabulary, not the
* facility/doorstep binary above. */
pickupSourceType,
pickupSourceTypeLabel: pickupSourceTypeLabel(pickupSourceType),
/** The base a Base -> Customer booking is collected FROM, when the row
* names one. Null for every other kind of pickup. */
pickupHubId:
storedPickupHubId ??
(pickupSourceType === PICKUP_SOURCE_TYPE.HUB ? originAnchor?.hubid ?? null : null),
/** True when the type came off the row rather than out of geometry. */
sourceTypeFromServer: storedSourceType != null,
dropTarget,
originAnchor,
destAnchor,
pickupPointKey,
pickupPointName,
servicingHubId,
derived: !explicit
};
};
/**
* The flow fields to merge into a create-booking payload.
*
* Sent on every create even though the backend may not store them yet: an
* unknown key is ignored server-side, and the day it IS stored, every order
* created from that point carries its type explicitly instead of relying on
* the console re-deriving it from coordinates on each page load.
*
* `sourcehubid`/`hubid` are the pair that fixes zone attribution. The console
* has always READ `b.hubid`/`b.sourcehubid` off a booking (queries.js) and
* never once written them, which is why ZoneContext had to fall back to
* matching hub names against concatenated address text.
*/
export const buildFlowFields = ({
flow,
servicingHubId,
tenantLocationId,
pickupAnchor = null,
pickupSourceType
} = {}) => {
const resolved = isValidFlow(flow) ? flow : FLOW.FORWARD;
const fields = { ordertype: resolved };
if (servicingHubId != null && servicingHubId !== '') {
fields.sourcehubid = Number(servicingHubId) || servicingHubId;
fields.hubid = fields.sourcehubid;
}
if (tenantLocationId != null && tenantLocationId !== '') {
fields.tenantlocationid = Number(tenantLocationId) || tenantLocationId;
}
// pickup_source_type is stored and returned by the backend, and the rider app
// reads it to title the stop. It is sent on EVERY create, "customer"
// included — an omission would be read as "nobody filled this in", which is a
// different fact from "this is somebody's front door".
//
// A caller-supplied type wins only if it is one of the known values; anything
// else falls back to what the matched anchor says, so a typo cannot put a word
// on the wire that no reader has a meaning for.
const resolvedType = isKnownSourceType(pickupSourceType)
? pickupSourceType
: sourceTypeForAnchor(pickupAnchor);
fields.pickup_source_type = resolvedType;
// Only a base pickup names a base. Sending pickuphubid alongside a merchant
// or customer type would tell the backend this is a Base -> Customer run when
// it is not.
if (resolvedType === PICKUP_SOURCE_TYPE.HUB) {
const baseId = pickupAnchor?.kind === 'hub' ? pickupAnchor.hubid : null;
if (baseId != null && baseId !== '') {
fields.pickuphubid = Number(baseId) || baseId;
}
}
return fields;
};
/**
* Derive the flow from what the CREATE form knows, before a booking exists.
*
* BOTH ends are measured, and geometry outranks the operator's toggle.
*
* That is not pedantry — it is what keeps the write path and the read path
* telling the same story. Whatever this stamps on the payload, classifyBooking
* will later re-derive from the very same coordinates when the board loads the
* booking back. If the two used different rules the same order would read "Point
* to Point" in one place and "Hub Pickup" in another, which is precisely the
* kind of disagreement this module exists to end.
*
* A real case makes it concrete: a tenant's bulk file collects from three home
* kitchens, one of which IS their registered business location. Those rows are
* hub pickups however the operator set the toggle, because that is what the
* coordinates say — and because the board will say so regardless.
*
* The toggle still decides when geometry cannot: a pickup with no usable
* coordinates falls back to what the operator declared.
*/
export const flowForDraft = ({
pickupSource,
pickupLat,
pickupLng,
dropLat,
dropLng,
anchors = []
} = {}) => {
const originAnchor = matchAnchor(pickupLat, pickupLng, anchors);
const destAnchor = matchAnchor(dropLat, dropLng, anchors);
const source = isUsablePoint(pickupLat, pickupLng)
? originAnchor
? PICKUP_SOURCE.HUB
: PICKUP_SOURCE.CUSTOMER
: pickupSource === PICKUP_SOURCE.CUSTOMER
? PICKUP_SOURCE.CUSTOMER
: PICKUP_SOURCE.HUB;
const target = destAnchor && isUsablePoint(dropLat, dropLng) ? DROP_TARGET.HUB : DROP_TARGET.CUSTOMER;
return { flow: FLOW_BY_ENDPOINTS[`${source}|${target}`], originAnchor, destAnchor };
};

78
src/lib/routingSummary.js Normal file
View File

@@ -0,0 +1,78 @@
// ==============================|| Routing decision, in words ||============================== //
//
// `GET /admin/bookings/:id` returns a `routing` block: the decision the backend
// took about where a parcel goes, and the inputs it took it from. This turns
// that into the sentences the order drawer shows.
//
// It exists so support can answer "why does the rider's screen say hand over at
// a base instead of deliver to the customer?" as a lookup instead of
// reconstructing a pincode rule by hand on the phone.
//
// TWO RULES THIS FILE EXISTS TO HOLD:
//
// 1. A PROJECTION IS NOT A DECISION. Before pickup, nothing has been decided —
// `routing.decided` is false and what is shown is what WILL happen. Saying
// "this went via a base" about a parcel nobody has collected yet is a
// wrong answer delivered confidently, which is worse than no answer.
//
// 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens here, at the
// edge, and never travels back up: every value sent to the backend keeps
// its wire spelling.
/** What the rider does next with the parcel. Keys are the backend's wire
* values; an unrecognised one is shown as itself rather than swallowed —
* the backend may add actions, and a blank row is worse than a raw word. */
export const NEXT_ACTION_LABEL = Object.freeze({
pickup: 'Collect from the pickup point',
inward_at_hub: 'Carry to a base and hand over',
start_delivery: 'Start the delivery run',
deliver: 'Deliver to the receiver',
handed_to_hub: 'Handed over at the base',
none: 'Nothing further for the rider'
});
export const nextActionLabel = (action) => NEXT_ACTION_LABEL[action] || action || '—';
/** Why the parcel is going the way it is, in one sentence an operator can read
* to a rider over the phone. */
export const routingReason = (routing) => {
if (!routing) return '';
return routing.is_hyperlocal
? 'Same postal area — the collecting rider carries it straight to the receiver.'
: 'Different postal area — it goes through a base rather than direct to the receiver.';
};
/** The from → to line, with the honesty caveat when nothing has been decided
* yet. A booking not yet collected has no routing FACT, only a forecast. */
export const routingRoute = (routing) => {
if (!routing) return '';
const from = routing.from_pincode || '—';
const to = routing.destination_pincode || '—';
const line = `${from} → ${to}`;
return routing.decided ? line : `${line} · not yet collected, so this is what will happen, not what has`;
};
/** The base a parcel is routed to, as one readable line. Null when no base is
* involved — which is the correct answer for a hyperlocal parcel, not a gap. */
export const baseLine = (base) => {
if (!base || !base.name) return null;
return base.pincode ? `${base.name} · ${base.pincode}` : base.name;
};
/**
* Everything the drawer renders, in one call, so the component holds no
* decisions of its own.
*/
export const summariseRouting = (routing) => {
if (!routing) return null;
return {
decided: Boolean(routing.decided),
reason: routingReason(routing),
route: routingRoute(routing),
nextAction: nextActionLabel(routing.next_action),
base: baseLine(routing.next_hub),
baseAddress: routing.next_hub?.address || null,
consignmentState: routing.consignment_state || '—',
inwardedAt: routing.inwardedat || null
};
};

View File

@@ -7,5 +7,5 @@ import '@/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
)
);

View File

@@ -0,0 +1,592 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import {
Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet,
Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle,
} from 'lucide-react';
import {
Alert, Button, DataTable, Drawer, EmptyState, Grid, KpiCard,
Modal, PageHeader, Stack, StatusBadge, Surface, Tabs,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import { OpenToast, messageOf } from '@/api/doormile/notify';
import { drainBookings } from '@/lib/assistant/scan';
import {
useBooking, useAssignMilerToBooking, useRiderOptions,
useCancelBooking, useBulkCancelBookings,
} from '@/lib/doormileHooks';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
import {
BOOKING_TABS, bookingsForTab, customerAppBookings,
quotedPrice, serviceOptionOf, serviceType,
} from '@/lib/customerAppBookings';
import { currency, exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
/**
* Bookings — customer app (doormile_cx) bookings management with tab separation
* (Created, Rider Assigned, Delivered, Cancelled) and bulk rider assignment.
*/
const EXPORT_COLUMNS = [
{ key: 'bookingno', header: 'Booking' },
{ key: 'status', header: 'Status' },
{ key: 'pickupaddress', header: 'Pickup' },
{ key: 'pickuppincode', header: 'Pickup pincode' },
{ key: 'deliveryaddress', header: 'Drop' },
{ key: 'deliverypincode', header: 'Drop pincode' },
{ key: 'service', header: 'Service', value: (row) => serviceType(row) },
{ key: 'price', header: 'Quoted', value: (row) => quotedPrice(row) },
{ key: 'createdat', header: 'Booked' },
];
/** One booking in full. Fetched on open rather than up front. */
function BookingDrawer({ bookingId, onClose }) {
const { data: booking, isLoading, refetch: refetchBooking } = useBooking(bookingId);
const { data: riders = [] } = useRiderOptions();
const assignMutation = useAssignMilerToBooking();
const [selectedRiderId, setSelectedRiderId] = useState('');
const parcels = booking?.parcels || [];
const payments = booking?.payments || [];
const price = serviceOptionOf(booking);
const assignedUserId = booking?.assignedmileruserid || booking?.mileruserid;
const assignedRider = useMemo(() => {
if (!assignedUserId) return null;
return riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(assignedUserId));
}, [assignedUserId, riders]);
const handleAssign = async () => {
if (!selectedRiderId || !bookingId) return;
const target = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
const targetUserId = target?.userid || target?.mileruserid || target?.id || selectedRiderId;
assignMutation.mutate(
{ id: bookingId, mileruserid: Number(targetUserId) },
{
onSuccess: () => {
setSelectedRiderId('');
refetchBooking();
},
}
);
};
const isAssignable = booking?.status !== 'Cancelled' && booking?.status !== 'Delivered';
return (
<Drawer
open={Boolean(bookingId)}
onOpenChange={(next) => !next && onClose()}
title={booking?.bookingno ? `Booking ${booking.bookingno}` : 'Booking'}
icon={PackageSearch}
size="lg"
>
{isLoading ? (
<p className="text-body-sm text-ink-3">Loading booking…</p>
) : !booking ? (
<p className="text-body-sm text-ink-3">This booking could not be loaded.</p>
) : (
<Stack space="default">
<Surface variant="subtle" padding="sm" radius="lg">
<dl className="grid grid-cols-2 gap-3">
{[
['Status', <StatusBadge status={booking.consignmentstatus || booking.status} size="sm" key="s" />],
['Booked', formatDoormileTimestamp(booking.createdat)],
['Collected from', pickupSourceTypeLabel(booking.pickup_source_type)],
['Consignment', orDash(booking.consignmentid)],
['Assigned Rider', assignedRider?.displayname || assignedRider?.name || (assignedUserId ? `Rider #${assignedUserId}` : 'Unassigned')],
].map(([label, value]) => (
<div key={label}>
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
<dd className="text-body-sm text-ink-1">{value}</dd>
</div>
))}
</dl>
</Surface>
{isAssignable && (
<Surface variant="card" padding="sm" radius="lg">
<p className="mb-2 text-overline uppercase text-ink-4">
{assignedUserId ? 'Reassign Rider' : 'Assign Rider'}
</p>
<div className="flex items-center gap-2">
<div className="flex-1">
<Select value={selectedRiderId} onValueChange={setSelectedRiderId}>
<SelectTrigger className="w-full">
<SelectValue placeholder={assignedRider?.displayname || assignedRider?.name || 'Select rider…'} />
</SelectTrigger>
<SelectContent>
{riders.map((r) => {
const id = String(r.userid || r.mileruserid || r.id);
return (
<SelectItem key={id} value={id}>
{r.label || r.displayname || r.name || `Rider #${id}`}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Button
variant="primary"
size="sm"
onClick={handleAssign}
loading={assignMutation.isPending}
disabled={!selectedRiderId || assignMutation.isPending}
>
Assign
</Button>
</div>
</Surface>
)}
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
<p className="text-body-sm text-ink-2">{orDash(booking.pickupaddress)}</p>
<p className="text-caption text-ink-3">{orDash(booking.pickuppincode)}</p>
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
<p className="text-body-sm text-ink-2">{orDash(booking.deliveryaddress)}</p>
<p className="text-caption text-ink-3">{orDash(booking.deliverypincode)}</p>
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Price</p>
{!price ? (
<p className="text-body-sm text-ink-3">No price was quoted for this booking.</p>
) : (
<Surface variant="subtle" padding="sm" radius="lg">
<dl className="grid grid-cols-2 gap-3">
{[
['Service', orDash(price.servicetype)],
['Quoted', currency(price.estimatedprice)],
['Expected delivery', formatDoormileTimestamp(price.estimateddeliveryat)],
['SLA due', formatDoormileTimestamp(price.sladueat)],
].map(([label, value]) => (
<div key={label}>
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
<dd className="text-body-sm text-ink-1">{value}</dd>
</div>
))}
</dl>
</Surface>
)}
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">
Parcels {parcels.length ? `· ${parcels.length}` : ''}
</p>
{parcels.length === 0 ? (
<p className="text-body-sm text-ink-3">No parcel detail recorded.</p>
) : (
<Stack space="xs">
{parcels.map((parcel, i) => (
<Surface key={i} variant="subtle" padding="sm" radius="lg">
<p className="text-body-sm font-medium text-ink-1">{orDash(parcel.itemcategory)}</p>
<p className="text-caption text-ink-3">
{orDash(parcel.itemdescription)}
{parcel.weight ? ` · ${parcel.weight} kg` : ''}
{parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
</p>
</Surface>
))}
</Stack>
)}
</div>
{payments.length > 0 && (
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Payments</p>
<Stack space="xs">
{payments.map((p, i) => (
<Surface key={i} variant="subtle" padding="sm" radius="lg">
<p className="text-body-sm text-ink-1">
{currency(p.amount)} · {orDash(p.paymentmode)}
</p>
<p className="text-caption text-ink-3">{orDash(p.paymentstatus)}</p>
</Surface>
))}
</Stack>
</div>
)}
</Stack>
)}
</Drawer>
);
}
export default function Bookings() {
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [openId, setOpenId] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
const [manualAssignOpen, setManualAssignOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [selectedRiderId, setSelectedRiderId] = useState('');
const [isAssigning, setIsAssigning] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const queryClient = useQueryClient();
const debouncedSearch = useDebouncedValue(search, 250);
const { data: riders = [] } = useRiderOptions();
const { data: scan, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: ['doormile', 'bookings', 'all'],
queryFn: () => drainBookings({ cached: false }),
refetchInterval: 15_000,
refetchOnMount: 'always',
});
const bookings = useMemo(() => customerAppBookings(scan?.rows), [scan]);
// Tab counts
const counts = useMemo(() => {
return {
all: bookings.length,
created: bookingsForTab(bookings, 'created').length,
assigned: bookingsForTab(bookings, 'assigned').length,
delivered: bookingsForTab(bookings, 'delivered').length,
cancelled: bookingsForTab(bookings, 'cancelled').length,
};
}, [bookings]);
const rows = useMemo(
() =>
bookingsForTab(bookings, tab).filter((b) =>
matchesQuery(b, ['bookingno', 'pickupaddress', 'deliveryaddress', 'status'], debouncedSearch)
),
[bookings, tab, debouncedSearch]
);
const selectedOrders = useMemo(
() => rows.filter((row) => selectedIds.includes(row.bookingid)),
[rows, selectedIds]
);
// Clear selection when tab changes
useEffect(() => {
setSelectedIds([]);
}, [tab]);
const stats = useMemo(() => {
const created = counts.created;
const value = bookings.reduce((sum, b) => sum + quotedPrice(b), 0);
return { total: bookings.length, created, value };
}, [bookings, counts]);
const riderLookup = useMemo(() => {
const map = new Map();
riders.forEach((r) => {
const id = String(r.userid || r.mileruserid || r.id);
map.set(id, r.displayname || r.name || r.label);
});
return map;
}, [riders]);
// Bulk manual assign handler
const handleBulkManualAssign = async () => {
if (!selectedRiderId || !selectedIds.length) {
OpenToast('Please select a rider first', 'warning');
return;
}
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
if (!targetRider) {
OpenToast('Please select a valid rider', 'warning');
return;
}
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
setIsAssigning(true);
try {
await Promise.all(
selectedIds.map((bookingId) =>
assignMilerToBooking(bookingId, { mileruserid: Number(riderUserId) })
)
);
if (targetRider.milerprofileid) {
notifyMiler(
targetRider.milerprofileid,
'DoormileXpress',
`${selectedIds.length} customer booking(s) assigned to you`
).catch(() => {});
}
OpenToast(
`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`,
'success'
);
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
refetch();
setSelectedIds([]);
setManualAssignOpen(false);
setSelectedRiderId('');
} catch (err) {
OpenToast(messageOf(err, 'Failed to assign orders'), 'error', 4000);
} finally {
setIsAssigning(false);
}
};
// Bulk cancel handler
const handleBulkCancel = async () => {
if (!selectedIds.length) return;
setIsCancelling(true);
try {
const { bulkCancelBookings } = await import('@/api/doormile/endpoints');
await bulkCancelBookings(selectedIds);
OpenToast(`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} cancelled`, 'success');
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
refetch();
setSelectedIds([]);
setCancelOpen(false);
} catch (err) {
OpenToast(messageOf(err, 'Failed to cancel orders'), 'error', 4000);
} finally {
setIsCancelling(false);
}
};
const columns = useMemo(
() => [
{
key: 'booking',
header: 'Booking',
sortable: true,
accessor: (row) => (
<div>
<p className="text-body-sm font-semibold text-ink-1">{orDash(row.bookingno)}</p>
<p className="text-caption text-ink-3">{formatDoormileTimestamp(row.createdat)}</p>
</div>
),
},
{
key: 'route',
header: 'Route',
accessor: (row) => (
<div className="max-w-xs">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
{orDash(row.pickupaddress)}
</p>
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
→ {orDash(row.deliveryaddress)}
</p>
</div>
),
},
{
key: 'rider',
header: 'Rider',
hideBelow: 'md',
accessor: (row) => {
const riderId = String(row.assignedmileruserid || row.mileruserid || '');
const name = riderLookup.get(riderId);
if (name) {
return <span className="text-body-sm font-medium text-ink-1">{name}</span>;
}
if (riderId) {
return <span className="text-caption text-ink-2">Rider #{riderId}</span>;
}
return <span className="text-caption text-ink-4">Unassigned</span>;
},
},
{ key: 'service', header: 'Service', hideBelow: 'md', accessor: (row) => orDash(serviceType(row)) },
{
key: 'price',
header: 'Price',
sortable: true,
accessor: (row) => (quotedPrice(row) ? currency(quotedPrice(row)) : '—'),
},
{
key: 'status',
header: 'Status',
accessor: (row) => <StatusBadge status={row.consignmentstatus || row.status} size="sm" />,
},
],
[riderLookup]
);
const canAssign = tab === 'created' && selectedOrders.length > 0;
return (
<Stack space="lg">
<PageHeader
title="Bookings"
subtitle="Booked by customers in the app — the counterpart to Orders, which this console creates."
icon={BookMarked}
actions={
<Button variant="outline" onClick={() => refetch()} loading={isFetching}>
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
</Button>
}
/>
{isError && <Alert tone="danger">{messageOf(error, 'Could not load bookings')}</Alert>}
{scan?.truncated && (
<Alert tone="warning">
Showing the most recent {Number(scan.scanned || 0).toLocaleString('en-IN')} of{' '}
{Number(scan.total || 0).toLocaleString('en-IN')} bookings. The counts below are a
floor, not a total.
</Alert>
)}
<Grid cols={3} gap="default">
<KpiCard label="Customer bookings" value={stats.total} />
<KpiCard label="Created (Awaiting Rider)" value={stats.created} />
<KpiCard label="Total quoted" value={currency(stats.value)} />
</Grid>
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs
tabs={BOOKING_TABS.map((t) => ({
value: t.value,
label: t.label,
count: counts[t.value] ?? 0,
}))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<ListToolbar
search={search}
onSearchChange={setSearch}
placeholder="Search booking no, address or status"
onExport={() => exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}`)}
exportDisabled={rows.length === 0}
/>
</div>
</div>
<DataTable
columns={columns}
rows={rows}
getRowId={(row) => row.bookingid}
loading={isLoading}
refreshing={isFetching && !isLoading}
selectedIds={tab === 'created' ? selectedIds : undefined}
onSelectionChange={tab === 'created' ? setSelectedIds : undefined}
onRowClick={(row) => setOpenId(row.bookingid)}
pageSize={25}
isFiltered={Boolean(debouncedSearch)}
onClearFilters={() => setSearch('')}
emptyState={
debouncedSearch ? undefined : (
<EmptyState
icon={Package}
title={`No ${BOOKING_TABS.find((entry) => entry.value === tab)?.label.toLowerCase()} bookings`}
description="Customer app bookings will appear here as they are placed."
/>
)
}
/>
{/* Floating Bulk Action Bar */}
{canAssign && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="fixed bottom-6 right-6 z-50 flex flex-wrap items-center gap-2.5 rounded-2xl border border-slate-200 bg-white/95 p-2.5 shadow-2xl backdrop-blur-md"
>
<div className="px-2 text-xs font-bold text-slate-700">
{selectedOrders.length} selected
</div>
<Button
variant="default"
className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-xs"
onClick={() => setManualAssignOpen(true)}
>
<Bike className="mr-1.5 h-4 w-4" /> Assign Rider ({selectedOrders.length})
</Button>
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setCancelOpen(true)}
>
<Ban className="mr-1.5 h-4 w-4" /> Cancel
</Button>
</motion.div>
)}
{/* Bulk Assign Modal */}
<Modal
open={manualAssignOpen}
onOpenChange={setManualAssignOpen}
title={`Assign Rider to ${selectedIds.length} Booking${selectedIds.length === 1 ? '' : 's'}`}
description="Choose an active rider to assign the selected customer bookings directly."
>
<div className="space-y-4 pt-2">
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
Select Rider <span className="text-red-500">*</span>
</label>
<select
value={selectedRiderId}
onChange={(e) => setSelectedRiderId(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-2xs focus:border-emerald-500 focus:outline-hidden focus:ring-2 focus:ring-emerald-500/20"
>
<option value="">-- Choose Rider --</option>
{riders.map((r) => {
const id = String(r.userid || r.mileruserid || r.id);
return (
<option key={id} value={id}>
{r.displayname || r.name || `Rider #${id}`} {r.phone ? `(${r.phone})` : ''}
</option>
);
})}
</select>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-slate-100">
<Button variant="outline" onClick={() => setManualAssignOpen(false)} disabled={isAssigning}>
Close
</Button>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleBulkManualAssign}
loading={isAssigning}
disabled={!selectedRiderId || isAssigning}
>
Confirm & Assign
</Button>
</div>
</div>
</Modal>
{/* Cancel Confirmation Modal */}
<Modal
open={cancelOpen}
onOpenChange={setCancelOpen}
title={`Cancel ${selectedIds.length} Customer Booking${selectedIds.length === 1 ? '' : 's'}`}
description="Are you sure you want to cancel the selected bookings? This action cannot be undone."
>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={isCancelling}>
Go back
</Button>
<Button variant="destructive" onClick={handleBulkCancel} loading={isCancelling}>
Cancel Bookings
</Button>
</div>
</Modal>
<BookingDrawer bookingId={openId} onClose={() => setOpenId(null)} />
</Stack>
);
}

View File

@@ -16,6 +16,8 @@ import { useZone } from '@/lib/ZoneContext';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
import { summariseRouting } from '@/lib/routingSummary';
/**
* Deliveries — orders that have moved past merely being created.
@@ -640,6 +642,59 @@ export default function Deliveries() {
);
}
/**
* How this booking was routed, and on what.
*
* Backend decides the route; this only reports the decision and the inputs it
* was made from, so support can answer "why does the rider's screen say hand
* over at a base instead of deliver?" without reconstructing a pincode rule by
* hand.
*
* Before pickup the decision has not been taken — `decided` is false and what is
* shown is a projection from the captured from/to, labelled as such. A number
* presented as a decision when it is only a forecast is worse than no number.
*
* Wording note: the wire says hub, operators and riders say base. The
* translation happens here, at the edge, and never travels back up.
*/
function RoutingSummary({ routing }) {
const s = summariseRouting(routing);
if (!s) return null;
return (
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Routing</p>
<Surface variant="subtle" padding="sm" radius="lg">
<Stack space="xs">
<p className="text-body-sm text-ink-1">{s.reason}</p>
<p className="text-caption text-ink-3">{s.route}</p>
<dl className="grid grid-cols-2 gap-3 pt-1">
{[
['Collected from', pickupSourceTypeLabel(routing.pickup_source_type)],
['Parcel state', s.consignmentState],
['Next step', s.nextAction],
['Base', s.base || '—'],
].map(([label, value]) => (
<div key={label}>
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
<dd className="text-body-sm text-ink-1">{value}</dd>
</div>
))}
</dl>
{s.baseAddress ? <p className="text-caption text-ink-3">{s.baseAddress}</p> : null}
{s.inwardedAt ? (
<p className="text-caption text-ink-3">
Received at the base {formatDoormileTimestamp(s.inwardedAt)}
</p>
) : null}
</Stack>
</Surface>
</div>
);
}
/**
* What is actually in the parcel.
*
@@ -687,14 +742,40 @@ function OrderDetailDrawer({ row, onClose }) {
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
<p className="text-body-sm text-ink-2">{orDash(row?.pickupaddress)}</p>
<p className="text-body-sm text-ink-2">
{orDash(booking?.routing?.from_address || row?.pickupaddress)}
</p>
{booking?.routing ? (
<p className="text-caption text-ink-3">
{[
pickupSourceTypeLabel(booking.routing.pickup_source_type),
booking.routing.pickup_source_name,
booking.routing.from_pincode
]
.filter(Boolean)
.join(' · ')}
</p>
) : null}
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
<p className="text-body-sm text-ink-2">{orDash(row?.deliveryaddress)}</p>
<p className="text-body-sm text-ink-2">
{orDash(booking?.routing?.to_address || row?.deliveryaddress)}
</p>
{booking?.routing?.destination_pincode ? (
<p className="text-caption text-ink-3">{booking.routing.destination_pincode}</p>
) : null}
</div>
{/* Why this parcel is going where it is going.
A rider rings asking why his screen says hand over at a base
instead of deliver to the customer; without this, answering meant
reconstructing the routing rule from two pincodes by hand. The
backend decides the route and reports it here with the inputs it
used, so the answer is a lookup. */}
{booking?.routing ? <RoutingSummary routing={booking.routing} /> : null}
<div>
<p className="mb-2 text-overline uppercase text-ink-4">
Parcels {parcels.length ? `· ${parcels.length}` : ''}

View File

@@ -2886,6 +2886,30 @@
letter-spacing: 0.05em;
}
/* Flow chips. The class is built from the raw flow name
(`type-${ordertype.toLowerCase()}`), so these four selectors track FLOW in
lib/orderFlow.js. `type-forward` has no rule on purpose: an ordinary hub run
never renders a chip at all (see flowChipLabel), and a rule for it would only
fire if that ever changed. The colours match their map markers — violet is a
doorstep collection in both places. */
.dispatch-container .zone-order-chip.type-reverse {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.3);
color: #7c3aed;
}
.dispatch-container .zone-order-chip.type-p2p {
background: rgba(14, 165, 233, 0.1);
border-color: rgba(14, 165, 233, 0.3);
color: #0284c7;
}
.dispatch-container .zone-order-chip.type-linehaul {
background: rgba(100, 116, 139, 0.12);
border-color: rgba(100, 116, 139, 0.3);
color: #475569;
}
.dispatch-container .kitchen-transition {
padding: 12px;
background: var(--kitchen-soft);
@@ -4620,6 +4644,16 @@
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 40px rgba(245, 158, 11, 0.4);
}
/* A doorstep collection is not a kitchen. Same shape and size — it is still a
collection point and still groups a rider's stops — but violet rather than
amber, so a dispatcher scanning the map can tell "load here, this is ours"
from "go out and collect here" without opening the pin. The box-shadow is
pinned inline by createKitchenIcon() (it carries the matching violet), which
is why only the fill is set here. */
.dispatch-container .kitchen-mark.is-customer-pickup {
background: #8b5cf6;
}
/* Focused kitchen marker — larger, brighter, with a pulsing halo so users
never lose sight of the kitchen they drilled into. */
.dispatch-container .kitchen-mark.is-focused {

View File

@@ -61,6 +61,7 @@ import { OpenToast } from 'components/third-party/OpenToast';
import { BATCHES as SHARED_BATCHES } from 'utils/batchBucket';
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { FLOW } from '@/lib/orderFlow';
import {
onActivate,
getStatusStyle,
@@ -156,6 +157,38 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF
// the same order under two different keys depending on which code path ran).
const pickupName = (o) => o.pickupcustomer || o.kitchen_key || o.locationname || o.pickuplocation || o.pickupsuburb || null;
// The STABLE identity of an order's collection point — what everything below
// groups on. The display NAME is not that identity, for two reasons:
//
// • Two different facilities can carry the same name, and grouping by name
// silently merges them into one pin.
// • A customer pickup has no facility name at all. Every customer pickup for
// one client used to resolve to the same string (the tenant's name, see
// queries.js) and therefore to ONE map pin, placed at whichever of those
// doorsteps happened to sort first. Ten collections across the city drew
// one marker.
//
// queries.js supplies `pickuppointkey` — one key per facility, one key per
// BOOKING for a customer pickup. The name fallback is kept for order objects
// that never went through that normaliser (the optimiser preview builds its
// own rows), so those behave exactly as they did before.
const pickupPointKey = (o) =>
o.pickuppointkey || (pickupName(o) || 'Unknown').toLowerCase().trim();
// Does this order require the rider to travel out to a non-facility address to
// collect? Read from the classification queries.js already did; falls back to
// false so an un-normalised row routes the way it always has.
const isCustomerPickup = (o) => o.pickupsource === 'customer';
// Text for the flow chip on an order card, or null for no chip.
//
// Deliberately silent on a plain hub run. That is the overwhelming majority of
// every board, and a chip on every card is a chip on none — the point of the
// badge is that a doorstep collection or a point-to-point job stands out from
// the wall of ordinary outbound work around it.
const flowChipLabel = (o) =>
o.ordertype && o.ordertype !== FLOW.FORWARD ? o.ordertypelabel || o.ordertype : null;
// Named delivery batches — operator's mental model of the day's waves.
// Each entry covers a half-open range [startHour, endHour) measured in
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
@@ -403,19 +436,74 @@ const POPUP_TIMELINE = [
{ key: 'deliverytime', label: 'Delivered', final: true }
];
// Build a polyline-ready point list for a sorted trip:
// - drop NaN drops
// - prepend the first valid pickup we can find (so the line starts at the kitchen)
const buildTripPoints = (sorted) => {
const valid = sorted.filter(hasValidDrop);
if (!valid.length) return [];
const pickupSrc = sorted.find(hasValidPickup);
const pts = [];
if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]);
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
return pts;
// Two points close enough to be the same physical visit. A hub pickup copies
// the facility's coordinates onto every booking, so consecutive stops off one
// kitchen are bit-identical; the tolerance covers a re-pinned gate.
const TRIP_POINT_MERGE_M = 50;
const isSameTripPoint = (a, b) => {
if (!a || !b) return false;
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)) <= TRIP_POINT_MERGE_M;
};
// Build a polyline-ready point list for a sorted trip, as the sequence of
// physical visits the rider actually makes.
//
// This used to be [one pickup, drop, drop, drop, …] — find the FIRST order with
// usable pickup coordinates, put it at the front, then chain every drop. That
// is only true when every stop is collected from the same place, which is true
// for a hub run and false for everything else. A customer pickup collects at a
// doorstep, so its collection point is a real stop on the route; drawing the
// rider's line straight from the kitchen to the delivery address skipped the
// entire first-mile leg and under-reported the distance it covers.
//
// The rule now: a collection point is emitted when the rider actually travels
// to it — always for a customer pickup (each doorstep is its own visit), and
// for a facility only when the rider is not already loaded there (first stop,
// or a switch to a different kitchen, which is the transition the sidebar
// already marks). Consecutive coincident points are merged.
//
// Returns the drop's index in `points` alongside, because callers colour
// per-step segments and previously had to INFER whether a pickup was prepended
// by comparing array lengths (`points.length > drops.length`). That inference
// held only while at most one pickup could ever appear.
const buildTripLegs = (sorted) => {
const valid = (sorted || []).filter(hasValidDrop);
const points = [];
const dropIndices = [];
let loadedAtKey = null;
const push = (pt) => {
const last = points[points.length - 1];
if (isSameTripPoint(last, pt)) return points.length - 1;
points.push(pt);
return points.length - 1;
};
valid.forEach((o) => {
if (hasValidPickup(o)) {
const key = pickupPointKey(o);
if (isCustomerPickup(o) || key !== loadedAtKey) {
push([toNum(pickupLat(o)), toNum(pickupLon(o))]);
}
// A doorstep collection never leaves the rider "loaded" anywhere they can
// collect again, so it must not suppress the next facility visit.
loadedAtKey = isCustomerPickup(o) ? null : key;
}
dropIndices.push(push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
});
return { points, dropIndices, orders: valid };
};
const buildTripPoints = (sorted) => buildTripLegs(sorted).points;
const getTripCacheKey = (riderId, tripKey, points) => {
if (!points || points.length === 0) return `${riderId}-${tripKey}`;
const pointsSig = points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|');
@@ -1789,10 +1877,13 @@ const Dispatch = ({
}
});
// Collection points on the map. Keyed by pickupPointKey, NOT by display
// name: a customer pickup gets its own key per booking, so ten doorstep
// collections draw ten pins instead of one pin at an arbitrary one of them.
const kitchenMap = {};
orders.forEach((o) => {
const name = pickupName(o) || 'Unknown';
const key = name.toLowerCase().trim();
const key = pickupPointKey(o);
if (!kitchenMap[key]) {
kitchenMap[key] = {
id: key,
@@ -1800,10 +1891,16 @@ const Dispatch = ({
lat: toNum(pickupLat(o)),
lon: toNum(pickupLon(o)),
orders: [],
riders: new Set()
riders: new Set(),
// Drives the marker: a facility and a doorstep collection are not the
// same kind of place and must not read as the same kind of pin.
isCustomerPickup: isCustomerPickup(o),
flow: o.ordertype || null
};
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
// Upgrade to first valid pickup coords we see for this kitchen
// Upgrade to first valid pickup coords we see for this kitchen. Safe
// now in a way it was not before: sharing a key means genuinely being
// the same facility, rather than merely sharing a tenant.
kitchenMap[key].lat = toNum(pickupLat(o));
kitchenMap[key].lon = toNum(pickupLon(o));
}
@@ -2611,7 +2708,7 @@ const Dispatch = ({
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
@@ -2626,7 +2723,8 @@ const Dispatch = ({
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Aerial fallback — NaN-safe build
const aerialPath = buildTripPoints(sorted);
const aerialLegs = buildTripLegs(sorted);
const aerialPath = aerialLegs.points;
const isKitchenAerial = viewMode === 'kitchens' || focusedKitchen;
const path = roadPath || aerialPath;
@@ -2645,15 +2743,20 @@ const Dispatch = ({
const stepSegs = roadPath
? splitPolylineByDrops(roadPath, dropCoords)
: (() => {
const hasPickup = aerialPath.length > dropCoords.length;
// Each step's segment runs from wherever the rider was after the
// previous drop to this drop — including any collection point
// inserted in between, which is the whole first-mile leg on a
// customer pickup. Read from the indices buildTripLegs returns;
// this was previously inferred from `path.length > drops.length`,
// which silently assumed at most one pickup existed in the path.
const out = [];
for (let i = 0; i < dropCoords.length; i++) {
const a = hasPickup ? i : i - 1;
const b = hasPickup ? i + 1 : i;
if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) {
const b = aerialLegs.dropIndices[i];
const a = i === 0 ? 0 : aerialLegs.dropIndices[i - 1];
if (b == null || a == null || b >= aerialPath.length || a >= b) {
out.push([]);
} else {
out.push([aerialPath[a], aerialPath[b]]);
out.push(aerialPath.slice(a, b + 1));
}
}
return out;
@@ -2771,21 +2874,28 @@ const Dispatch = ({
const KITCHEN_ICON_SIZE = 31;
const KITCHEN_ICON_FOCUSED_SIZE = 38;
const createKitchenIcon = (name, focused = false) => {
// Collection-point marker. `customerPickup` swaps the amber facility glow for
// a violet one and the initial for an arrow, because a doorstep collection
// and a kitchen are not the same kind of place: a dispatcher scanning the map
// has to be able to tell "load here, it is ours" from "go and collect here"
// without opening the pin.
const createKitchenIcon = (name, focused = false, customerPickup = false) => {
const size = focused ? KITCHEN_ICON_FOCUSED_SIZE : KITCHEN_ICON_SIZE;
const anchor = Math.round(size / 2);
const border = focused ? 3 : 2;
const glow = 14;
const rgb = customerPickup ? '139,92,246' : '245,158,11';
const glyph = customerPickup ? '&uarr;' : (name || 'K').charAt(0).toUpperCase();
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [anchor, anchor],
popupAnchor: [0, -(anchor + 2)],
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}" style="width:${size}px;height:${size}px;font-size:${Math.round(
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}${
customerPickup ? ' is-customer-pickup' : ''
}" style="width:${size}px;height:${size}px;font-size:${Math.round(
size * 0.38
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(245,158,11,0.8), 0 0 ${glow * 2}px rgba(245,158,11,0.4)">${(name || 'K')
.charAt(0)
.toUpperCase()}</div>`
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(${rgb},0.8), 0 0 ${glow * 2}px rgba(${rgb},0.4)">${glyph}</div>`
});
};
@@ -3239,7 +3349,7 @@ const Dispatch = ({
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
@@ -3293,7 +3403,8 @@ const Dispatch = ({
// line is never invisible while OSRM is in flight.
if (!isAllActiveView && !hasRoad && !failed) return;
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : buildTripPoints(sorted);
const plannedLegs = buildTripLegs(sorted);
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : plannedLegs.points;
if (!finalPoints || finalPoints.length < 2) return;
const isKitchenView = viewMode === 'kitchens' || focusedKitchen;
@@ -3321,22 +3432,24 @@ const Dispatch = ({
const validDrops = sorted.filter(hasValidDrop);
const dropCoords = validDrops.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
// hasRoad: split the OSRM polyline at each drop's nearest index.
// !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints
// only prepends the pickup when one's available. Detect that and
// align segment[i] with validDrops[i] either way.
// !hasRoad: align segment[i] with validDrops[i] using the drop indices
// buildTripLegs returns. This was previously inferred from
// `finalPoints.length > dropCoords.length`, which assumed the path
// held at most one pickup — true for a pure hub run, wrong as soon as
// a trip carries doorstep collections, where every extra collection
// point shifted every later segment onto the wrong step's colour.
let segments;
if (hasRoad) {
segments = splitPolylineByDrops(finalPoints, dropCoords);
} else {
const hasPickup = finalPoints.length > dropCoords.length;
segments = [];
for (let i = 0; i < dropCoords.length; i++) {
const idxA = hasPickup ? i : i - 1;
const idxB = hasPickup ? i + 1 : i;
if (idxA < 0 || idxA >= finalPoints.length || idxB >= finalPoints.length) {
const idxB = plannedLegs.dropIndices[i];
const idxA = i === 0 ? 0 : plannedLegs.dropIndices[i - 1];
if (idxB == null || idxA == null || idxB >= finalPoints.length || idxA >= idxB) {
segments.push([]);
} else {
segments.push([finalPoints[idxA], finalPoints[idxB]]);
segments.push(finalPoints.slice(idxA, idxB + 1));
}
}
}
@@ -4369,7 +4482,7 @@ const Dispatch = ({
</div>
<div className="zone-order-grid">
{displayOrders.map((o, idx) => {
const kitchenKey = (pickupName(o) || 'Unknown').toLowerCase().trim();
const kitchenKey = pickupPointKey(o);
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
@@ -4401,7 +4514,8 @@ const Dispatch = ({
<span className="kt-ico">
<MdSwapHoriz />
</span>{' '}
Switch to <strong>{o.pickupcustomer}</strong>
{isCustomerPickup(o) ? 'Collect at' : 'Switch to'}{' '}
<strong>{o.pickupcustomer}</strong>
</div>
)}
<div
@@ -4536,11 +4650,11 @@ const Dispatch = ({
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
{flowChipLabel(o) && (
<span
className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}
>
{o.ordertype}
{flowChipLabel(o)}
</span>
)}
<span className="zone-order-chip zone-order-trip">
@@ -4742,9 +4856,9 @@ const Dispatch = ({
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
{flowChipLabel(o) && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
{flowChipLabel(o)}
</span>
)}
<span className="zone-order-chip zone-order-trip">
@@ -4936,9 +5050,9 @@ const Dispatch = ({
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
{flowChipLabel(o) && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
{flowChipLabel(o)}
</span>
)}
<span className="zone-order-chip zone-order-trip">
@@ -5218,7 +5332,7 @@ const Dispatch = ({
<Marker
key={`k-${i}`}
position={[k.lat, k.lon]}
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id)}
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id, k.isCustomerPickup)}
zIndexOffset={focusedKitchen?.id === k.id ? 4000 : 2000}
eventHandlers={{
click: () => setFocusedKitchen(k),

View File

@@ -6,6 +6,7 @@ import {
Stack, StatusBadge,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import { validateHubForm } from '@/lib/hubForm';
import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
@@ -114,23 +115,19 @@ export default function Hubs() {
};
const save = () => {
const nextErrors = {};
if (!form.hubname.trim()) nextErrors.hubname = 'Give the hub a name';
if (form.applocationid === '' || Number.isNaN(Number(form.applocationid))) {
nextErrors.applocationid = 'Enter the numeric city id';
}
/* A hub is a BASE to a rider — the six fields the backend hands the rider
app to name, describe and navigate to it. validateHubForm holds why each
one is required; it lives in lib/ so the rule can be tested on its own. */
const { errors: nextErrors, isValid, values } = validateHubForm(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
if (!isValid) return;
const { hubid, ...rest } = form;
const data = {
...rest,
applocationid: Number(rest.applocationid),
/* Coordinates are optional in the form but numeric on the wire — an empty
string here is rejected, whereas 0 is accepted and simply means the hub
has not been pinned on the map yet. */
latitude: Number(rest.latitude) || 0,
longitude: Number(rest.longitude) || 0,
applocationid: values.applocationid,
latitude: values.latitude,
longitude: values.longitude,
};
const mutation = hubid ? updateHub : createHub;
@@ -352,19 +349,21 @@ export default function Hubs() {
<Input value={form.contactno} onChange={(e) => set('contactno')(e.target.value)} placeholder="9876543210" />
</Field>
<Field label="Address" className="sm:col-span-2">
<Field label="Address" required error={errors.address} className="sm:col-span-2">
<Input value={form.address} onChange={(e) => set('address')(e.target.value)} placeholder="Street, area, landmark" />
</Field>
<Field label="Pincode">
<Field label="Pincode" required error={errors.pincode}>
<Input value={form.pincode} onChange={(e) => set('pincode')(e.target.value)} placeholder="641004" />
</Field>
{/* Riders navigate to these. A hub saved without them is a base the
app can name but cannot route to. */}
<div className="grid grid-cols-2 gap-4">
<Field label="Latitude">
<Field label="Latitude" required error={errors.latitude}>
<Input value={form.latitude} onChange={(e) => set('latitude')(e.target.value)} placeholder="11.0168" />
</Field>
<Field label="Longitude">
<Field label="Longitude" required error={errors.longitude}>
<Input value={form.longitude} onChange={(e) => set('longitude')(e.target.value)} placeholder="76.9558" />
</Field>
</div>

View File

@@ -0,0 +1,194 @@
import React from 'react';
import { MapPin, Package } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Drawer, EmptyState, LoadingState, StatusBadge } from '@/components/ds';
import { getBooking } from '@/api/doormile/endpoints';
import { currency } from '@/lib/doormileFormat';
import { plural } from '@/lib/bookingDrops';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* The legs of one pickup.
*
* A customer-app pickup is ONE booking carrying N destinations. The list can
* only say how many (`destinationcount`); the array itself is fetched here, on
* open, because the Orders list drains up to twelve pages of a hundred rows and
* would otherwise carry an array it never reads.
*
* Two things this deliberately does NOT do:
*
* - It never reads `booking.consignmentid` or `booking.consignmentstatus`.
* Pickup-complete sets those from the FIRST leg only, for the benefit of
* older single-drop screens, so on a three-drop pickup they are Chennai's
* status wearing the whole booking's name. Per-leg state comes from the
* destination's own `stage` and `consignmentid`, never from the booking's.
*
* - It never invents a tracking number, stage or ETA. Those are minted at
* pickup-complete (cxPickupFanout), so before the rider collects there is
* genuinely nothing to show, and a column of em dashes reads as missing
* data rather than as work that has not happened yet. The columns are
* dropped instead.
*/
/* The address the customer typed, in the order they typed it. Empty parts are
dropped rather than rendered as gaps: a destination may carry nothing but a
district until the customer completes it. */
const streetAddress = (destination) =>
[destination?.building, destination?.street, destination?.landmark]
.map((part) => String(part || '').trim())
.filter(Boolean)
.join(', ');
const DestinationRow = ({ destination, index, showShipmentColumns }) => {
const address = streetAddress(destination);
const expected = parseDoormileTimestamp(destination?.expecteddeliveryat);
const delivered = parseDoormileTimestamp(destination?.deliveredat);
const cod = Number(destination?.codamount) || 0;
const weight = Number(destination?.verifiedweightkg) || 0;
return (
<li className="flex gap-3 border-b border-border py-3 last:border-0">
{/* seq is the customer-facing position and the {index} in
PATCH /customer/bookings/{ref}/destinations/{index}. Shown one-based
as the customer counts them, and never re-sorted. */}
<span className="mt-0.5 shrink-0 text-caption font-semibold tabular-nums text-ink-3">
{String(index + 1).padStart(2, '0')}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="text-body-sm font-semibold text-ink-1">
{destination?.districtname || 'Destination'}
</p>
{destination?.statename && (
<span className="text-caption text-ink-3">{destination.statename}</span>
)}
<span className="text-caption text-ink-3">
{`· ${plural(Number(destination?.packagecount) || 0, 'package')}`}
</span>
{showShipmentColumns && destination?.stage && (
<StatusBadge status={destination.stage} dot size="sm" />
)}
</div>
{address && (
<p className="truncate text-caption text-ink-2" title={address}>
{address}
{destination?.pincode ? ` · ${destination.pincode}` : ''}
</p>
)}
{(destination?.recipientname || destination?.recipientphone) && (
<p className="text-caption text-ink-2">
{destination.recipientname || 'Recipient'}
{destination.recipientphone ? ` · ${destination.recipientphone}` : ''}
</p>
)}
{destination?.instructions && (
<p className="text-caption italic text-ink-3">{destination.instructions}</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
{showShipmentColumns && destination?.trackingno && (
<span className="font-medium tabular-nums text-ink-2">{destination.trackingno}</span>
)}
{cod > 0 && <span>{`COD ${currency(cod)}`}</span>}
{weight > 0 && <span>{`${weight} kg verified`}</span>}
{delivered.isValid() ? (
<span>{`Delivered ${delivered.format('DD MMM, hh:mm A')}`}</span>
) : (
expected.isValid() && <span>{`Expected ${expected.format('DD MMM, hh:mm A')}`}</span>
)}
</div>
</div>
</li>
);
};
export function BookingDestinationsDrawer({ bookingId, bookingNo, open, onOpenChange }) {
/* Fetched on open, never on list render. `enabled` is what keeps this from
firing for every row the operator scrolls past. */
const { data: booking, isLoading, isError } = useQuery({
queryKey: ['doormile', 'booking', bookingId],
queryFn: () => getBooking(bookingId),
enabled: Boolean(open && bookingId),
});
const destinations = Array.isArray(booking?.destinations) ? booking.destinations : [];
/* Tracking numbers and stages are minted together at pickup-complete, so
"any leg has one" is the same question as "has this pickup been collected".
Asked across all legs rather than per row, so the columns appear or vanish
as a set instead of ragged. */
const showShipmentColumns = destinations.some(
(destination) => destination?.trackingno || destination?.stage
);
const totalPackages = destinations.reduce(
(sum, destination) => sum + (Number(destination?.packagecount) || 0),
0
);
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
icon={MapPin}
title={bookingNo ? `${bookingNo} · drops` : 'Drops'}
description={
destinations.length
? `${plural(destinations.length, 'destination')} · ${plural(totalPackages, 'package')}`
: undefined
}
size="lg"
>
<div className="px-5 py-4">
{isLoading && <LoadingState message="Loading drops" />}
{isError && !isLoading && (
<EmptyState
icon={Package}
title="Could not load the drops"
description="The booking detail request failed. Close this and try again."
/>
)}
{!isLoading && !isError && destinations.length === 0 && (
/* Two different situations reach here and the copy has to fit both: a
console-created booking genuinely has no destination rows, and a
console running ahead of the backend gets no `destinations` key at
all. Neither is an error worth alarming an operator about. */
<EmptyState
icon={Package}
title="No separate drops recorded"
description="This booking has a single delivery address, shown on the row itself."
/>
)}
{!isLoading && destinations.length > 0 && (
<>
{!showShipmentColumns && (
<p className="mb-3 text-caption text-ink-3">
Tracking numbers appear once the rider completes pickup. Each drop becomes its own
shipment then.
</p>
)}
<ol className="min-w-0">
{destinations.map((destination, index) => (
<DestinationRow
key={destination?.bookingdestinationid ?? index}
destination={destination}
index={index}
showShipmentColumns={showShipmentColumns}
/>
))}
</ol>
</>
)}
</div>
</Drawer>
);
}
export default BookingDestinationsDrawer;

View File

@@ -20,7 +20,8 @@ import {
import { useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import AddressAutocomplete, { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
import {
createExpressBooking,
@@ -30,6 +31,14 @@ import {
getTenantLocations
} from '@/api/doormile/endpoints';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import { useHubs } from '@/lib/doormileHooks';
import {
PICKUP_SOURCE,
buildAnchors,
buildFlowFields,
flowForDraft,
flowLabel
} from '@/lib/orderFlow';
import { OpenToast } from '@/components/third-party/OpenToast';
const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
@@ -95,6 +104,13 @@ export default function CreateOrder() {
const [pickCust, setPickCust] = useState(EMPTY_PARTY);
const [dropCust, setDropCust] = useState(EMPTY_PARTY);
// Where the parcel is collected from. 'hub' copies the selected business
// location onto the pickup block, which is what this form has always done.
// 'customer' leaves the pickup fields to the operator, because the collection
// point is a doorstep that no saved location describes.
const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB);
const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER;
const [pickupDate, setPickupDate] = useState(dayjs().format('YYYY-MM-DD'));
const [selectedSlotTime, setSelectedSlotTime] = useState('09:00');
const [serviceOption, setServiceOption] = useState('Normal');
@@ -109,6 +125,9 @@ export default function CreateOrder() {
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [searchCustQuery, setSearchCustQuery] = useState('');
// Map pin picker modal target ('pickup' | 'drop' | null)
const [mapPickerTarget, setMapPickerTarget] = useState(null);
// Queries
const { data: staffTenants = [], isLoading: isTenantsLoading } = useQuery({
queryKey: ['adminTenants'],
@@ -132,6 +151,17 @@ export default function CreateOrder() {
queryFn: getAdminPricing
});
// Facilities, so a DROP that lands on one is recognised as a hub rather than
// treated as a customer's address. Without this, a rider bringing a parcel
// back to the hub would be booked as point-to-point and routed as if the hub
// were somebody's house.
const { data: hubList = [] } = useHubs();
const flowAnchors = useMemo(
() => buildAnchors({ hubs: hubList, tenantLocations: tenantLocations }),
[hubList, tenantLocations]
);
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
queryKey: ['adminCustomers'],
queryFn: getAdminCustomers,
@@ -148,24 +178,30 @@ export default function CreateOrder() {
const sortedLocations = useMemo(() => sortLocations(tenantLocations), [tenantLocations]);
// Auto-select single location
// Auto-select single location.
//
// The location is still selected under a customer pickup — it is the client's
// servicing origin and the order belongs to it — but its address is NOT
// copied over the pickup block, which would silently overwrite the doorstep
// the operator just typed with the kitchen's address.
useEffect(() => {
if (sortedLocations.length === 1) {
const loc = sortedLocations[0];
setSelectedLocationId(String(loc.tenantlocationid || loc.locationid));
setSelectedLocation(loc);
applyLocationToPickup(loc);
if (!isCustomerPickup) applyLocationToPickup(loc);
} else if (sortedLocations.length === 0) {
setSelectedLocationId('');
setSelectedLocation(null);
}
}, [sortedLocations]);
}, [sortedLocations, isCustomerPickup]);
const applyLocationToPickup = (loc) => {
if (!loc) return;
const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat;
const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong;
// @ts-ignore
setPickCust((prev) => ({
...prev,
firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point',
@@ -190,9 +226,42 @@ export default function CreateOrder() {
}
const loc = sortedLocations.find((l) => String(l.tenantlocationid || l.locationid) === locId);
setSelectedLocation(loc || null);
if (loc) applyLocationToPickup(loc);
if (loc && !isCustomerPickup) applyLocationToPickup(loc);
};
// Switching collection mode.
//
// Going to 'customer' CLEARS the pickup block rather than leaving the hub's
// address sitting in it. A half-cleared form is the dangerous state here: the
// operator retypes the address, misses the coordinates, and books a doorstep
// collection that is still pinned on the kitchen — which routes a rider to
// the wrong end of the city and prices the job off the wrong leg.
const handleChangePickupMode = (mode) => {
if (mode === pickupMode) return;
setPickupMode(mode);
if (mode === PICKUP_SOURCE.CUSTOMER) {
setPickCust(EMPTY_PARTY);
} else if (selectedLocation) {
applyLocationToPickup(selectedLocation);
}
};
// What this order will be booked as. The pickup end is whatever the operator
// chose; the drop end is measured, because a free-typed delivery address that
// lands on a facility is a return to the hub, not a delivery to a customer.
const draftFlow = useMemo(
() =>
flowForDraft({
pickupSource: pickupMode,
pickupLat: pickCust.latitude,
pickupLng: pickCust.longitude,
dropLat: dropCust.latitude,
dropLng: dropCust.longitude,
anchors: flowAnchors
}),
[pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors]
);
// Tenant Pricing
const clientRate = useMemo(() => {
const list = Array.isArray(pricingList) ? pricingList : [];
@@ -232,6 +301,45 @@ export default function CreateOrder() {
};
}, [pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude]);
// First-mile ("deadhead") leg.
//
// On a hub run the rider starts where the parcel is, so the only distance
// that exists is pickup → drop. A customer pickup adds a leg BEFORE that:
// the rider travels from the servicing location out to the customer's door,
// carrying nothing. It is real distance, real time and real fuel, and until
// now nothing in this console measured it.
//
// Measured and shown, NOT billed. What to charge for an empty leg is a
// tariff decision, and quietly folding it into every customer-pickup order's
// price would change what clients are invoiced without anyone choosing that.
// `totalCharge` below is deliberately left on the loaded leg alone.
const [deadhead, setDeadhead] = useState({ distance: 0, resolved: false });
useEffect(() => {
const oLat = Number(selectedLocation?.latitude);
const oLng = Number(selectedLocation?.longitude);
const pLat = Number(pickCust.latitude);
const pLng = Number(pickCust.longitude);
if (!isCustomerPickup || !oLat || !oLng || !pLat || !pLng) {
setDeadhead({ distance: 0, resolved: false });
return;
}
let active = true;
calculateDrivingDistance({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
.then((distance) => {
if (active) setDeadhead({ distance: Math.max(0, distance || 0), resolved: true });
})
.catch(() => {
if (active) setDeadhead({ distance: 0, resolved: false });
});
return () => {
active = false;
};
}, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]);
// Total Charge calculation
const totalCharge = useMemo(() => {
const base = Number(clientRate?.baseprice) || 40;
@@ -248,6 +356,7 @@ export default function CreateOrder() {
// Place selection helpers
const handlePlaceSelect = (partyType, place) => {
if (!place) return;
const parsed = { suburb: '', city: '', postcode: '' };
(place.address_components || []).forEach((c) => {
(c.types || []).forEach((type) => {
@@ -257,19 +366,22 @@ export default function CreateOrder() {
});
});
const lat = place.geometry?.location?.lat ? place.geometry.location.lat() : '';
const lng = place.geometry?.location?.lng ? place.geometry.location.lng() : '';
const address = place.formatted_address || '';
const lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : '');
const lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : '');
const address = place.formatted_address || place.name || '';
const suburb = place.suburb || parsed.suburb;
const city = place.city || parsed.city;
const postcode = place.postcode || parsed.postcode;
const setter = partyType === 'pickup' ? setPickCust : setDropCust;
setter((prev) => ({
...prev,
address,
suburb: parsed.suburb || prev.suburb,
city: parsed.city || prev.city,
postcode: parsed.postcode || prev.postcode,
latitude: lat,
longitude: lng
address: address || prev.address,
suburb: suburb || prev.suburb,
city: city || prev.city,
postcode: postcode || prev.postcode,
latitude: lat !== '' ? lat : prev.latitude,
longitude: lng !== '' ? lng : prev.longitude
}));
};
@@ -320,6 +432,21 @@ export default function CreateOrder() {
OpenToast('Enter pickup address', 'warning', 2500);
return;
}
// Pickup coordinates were never checked here. That was survivable only
// while the pickup was always a saved location, which supplies its own
// coordinates — a hand-typed collection address that fails to geocode
// submits 0,0, and an order pinned at 0,0 is unroutable, unpriceable and
// invisible to every zone filter.
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
OpenToast(
isCustomerPickup
? 'Pick the collection address from the suggestions, or drop a map pin, so it has coordinates'
: 'The selected hub has no coordinates — set them on the Hubs page before booking from it',
'warning',
4000
);
return;
}
if (!dropCust.firstname.trim()) {
OpenToast('Enter delivery contact name', 'warning', 2500);
return;
@@ -369,7 +496,23 @@ export default function CreateOrder() {
service_option: SERVICE_OPTIONS.includes(serviceOption) ? serviceOption : 'Normal',
finalprice,
notes: notes || '',
parcels
parcels,
// Which flow this is, and which hub owns it. Sent even where the backend
// may not store them yet: an unknown key is ignored server-side, and the
// console classifies from coordinates in the meantime, so nothing depends
// on these landing. The day they are persisted, every order created from
// here carries its own type instead of being re-derived on each load.
...buildFlowFields({
flow: draftFlow.flow,
servicingHubId:
draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null,
tenantLocationId: selectedLocationId,
// The collection point itself, so the order records what KIND of place
// it leaves from — a base, a client's site, or a customer's door. The
// backend stores it and the rider app titles the stop with it; without
// it every logistics pickup reads as the rider's own base name.
pickupAnchor: draftFlow.originAnchor
})
};
setIsSubmitting(true);
@@ -523,10 +666,77 @@ export default function CreateOrder() {
</div>
</div>
{selectedLocation && (
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
{selectedLocation.locationname || 'Hub Selected'}
{/* The badge names the COLLECTION POINT, so it has to stop
naming the hub the moment the hub is not where we collect. */}
{isCustomerPickup ? (
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-violet-50 text-violet-700 border border-violet-200">
{flowLabel(draftFlow.flow)}
</span>
) : (
selectedLocation && (
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
{selectedLocation.locationname || 'Hub Selected'}
</span>
)
)}
</div>
{/* Collect from the client's own location, or from a customer's
door. This is the switch the whole order flow turns on: it
decides whether the pickup block is filled from the saved
location or typed, and it is what the board later reads to
draw a doorstep collection as its own stop on the route. */}
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3">
<p className="text-[11px] font-semibold text-slate-700 mb-2">Collect from</p>
<div className="grid grid-cols-2 gap-2">
{[
{
mode: PICKUP_SOURCE.HUB,
icon: Building2,
title: 'Business Hub',
hint: 'Client kitchen, depot or branch'
},
{
mode: PICKUP_SOURCE.CUSTOMER,
icon: Users,
title: 'Customer Pickup',
hint: "Collect at a customer's own address"
}
].map(({ mode, icon: Icon, title, hint }) => {
const active = pickupMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => handleChangePickupMode(mode)}
aria-pressed={active}
className={`flex items-start gap-2.5 rounded-lg border p-2.5 text-left transition-colors cursor-pointer ${
active
? 'border-black bg-white shadow-2xs ring-2 ring-black/10'
: 'border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white'
}`}
>
<Icon
className={`w-4 h-4 mt-0.5 shrink-0 ${active ? 'text-slate-900' : 'text-slate-400'}`}
/>
<span className="min-w-0">
<span
className={`block text-xs font-bold ${active ? 'text-slate-900' : 'text-slate-600'}`}
>
{title}
</span>
<span className="block text-[10px] text-slate-500 leading-tight mt-0.5">{hint}</span>
</span>
</button>
);
})}
</div>
{isCustomerPickup && (
<p className="mt-2 text-[10px] text-violet-700 bg-violet-50 border border-violet-200 rounded-lg px-2.5 py-1.5 leading-relaxed">
The rider travels out to this address to collect. Pick it from the suggestions or drop a
map pin so it carries coordinates — the first-mile leg is priced and routed from them.
</p>
)}
</div>
@@ -566,10 +776,11 @@ export default function CreateOrder() {
id="pickup-address"
label="Pickup Address"
required
placeholder="Search address or type location"
placeholder="Search address, building or locality"
value={pickCust.address}
onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('pickup', place)}
onOpenMapPin={() => setMapPickerTarget('pickup')}
/>
</div>
@@ -676,11 +887,12 @@ export default function CreateOrder() {
id="drop-address"
label="Delivery Address"
required
placeholder="Search delivery address or locality"
placeholder="Search delivery address, society or landmark"
value={dropCust.address}
bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined}
onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('drop', place)}
onOpenMapPin={() => setMapPickerTarget('drop')}
/>
</div>
@@ -970,11 +1182,35 @@ export default function CreateOrder() {
{/* Journey Legs */}
<div className="space-y-3 py-1 text-xs">
{/* The empty run out to the customer's door. Drawn as its own
leg because that is what it is — the rider covers it before
the parcel exists on the trip. */}
{isCustomerPickup && selectedLocation && (
<>
<div className="flex items-start gap-2.5">
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 ring-4 ring-slate-100 mt-1 shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-semibold text-slate-500 truncate">
{selectedLocation.locationname || 'Servicing Hub'}
</div>
<div className="text-[11px] text-slate-400 line-clamp-1">
Rider starts here · travels empty
</div>
</div>
</div>
<div className="ml-1 pl-2.5 border-l border-dashed border-slate-200 h-2" />
</>
)}
<div className="flex items-start gap-2.5">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500 ring-4 ring-emerald-100 mt-1 shrink-0" />
<div
className={`w-2.5 h-2.5 rounded-full mt-1 shrink-0 ${
isCustomerPickup ? 'bg-violet-500 ring-4 ring-violet-100' : 'bg-emerald-500 ring-4 ring-emerald-100'
}`}
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-slate-900 truncate">
{pickCust.firstname || selectedLocation?.locationname || 'Pickup Point'}
{pickCust.firstname || (isCustomerPickup ? 'Collection Point' : selectedLocation?.locationname) || 'Pickup Point'}
</div>
<div className="text-[11px] text-slate-500 line-clamp-1">{pickCust.address || 'Address not selected'}</div>
</div>
@@ -1010,6 +1246,22 @@ export default function CreateOrder() {
</div>
</div>
{/* Shown only when it exists, and labelled as unbilled so nobody
reconciles the invoice against the wrong number. */}
{isCustomerPickup && deadhead.resolved && (
<div className="flex items-center justify-between rounded-lg border border-violet-200 bg-violet-50 px-3 py-2">
<div className="min-w-0">
<div className="text-[10px] font-bold text-violet-700 uppercase tracking-wider">
First-mile leg
</div>
<div className="text-[10px] text-violet-600 leading-tight">
Hub → collection point · not billed
</div>
</div>
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{deadhead.distance} km</div>
</div>
)}
{/* Rate Card Info */}
<div className="text-xs space-y-2 py-2 border-y border-slate-100">
<div className="flex justify-between text-slate-600">
@@ -1155,6 +1407,22 @@ export default function CreateOrder() {
</div>
</div>
)}
{/* Map Pin Picker Modal for Pinpoint Accuracy */}
<MapPinPickerModal
isOpen={Boolean(mapPickerTarget)}
onClose={() => setMapPickerTarget(null)}
title={mapPickerTarget === 'pickup' ? 'Pin Exact Pickup Location' : 'Pin Exact Delivery Location'}
initialAddress={mapPickerTarget === 'pickup' ? pickCust.address : dropCust.address}
initialLat={mapPickerTarget === 'pickup' ? pickCust.latitude : dropCust.latitude}
initialLng={mapPickerTarget === 'pickup' ? pickCust.longitude : dropCust.longitude}
onConfirm={(place) => {
if (mapPickerTarget) {
handlePlaceSelect(mapPickerTarget, place);
}
setMapPickerTarget(null);
}}
/>
</div>
);
}

View File

@@ -38,11 +38,13 @@ import {
requiredSheetColumns
} from '@/lib/bulkOrderColumns';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
import { useHubs } from '@/lib/doormileHooks';
import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow';
import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload';
import { OpenToast } from '@/components/third-party/OpenToast';
// Nominatim serves this console's geocoding for free and asks for no more than
// one request a second in return. See handleProcessUsersToDrops.
const GEOCODE_INTERVAL_MS = 1100;
// Multi-provider geocoding service allows sub-300ms lookups
const GEOCODE_INTERVAL_MS = 250;
const buildDefaultPickupSlots = () => {
const arr = [];
@@ -85,6 +87,21 @@ export default function MultipleOrders() {
const [pricePerKm, setPricePerKm] = useState(0);
const [minKm, setMinKm] = useState(0);
// Where every row in this run is collected from.
//
// 'hub' — one shared collection point, the selected business location.
// This is what the page has always done, and stays the default.
// 'customer' — each ROW carries its own collection point, read from the
// sheet's Sender Name / Sender Phone / Sender Address columns.
//
// Those sender columns are not new. SHEET_HEADER_MAP has always mapped them
// (to `locationname` / `locationcontact` / `locationaddress`) and the page has
// always parsed them and then thrown them away, overwriting every row's
// origin with the hub. A tenant sending a file of doorstep collections was
// therefore booking every one of them against the kitchen.
const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB);
const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER;
// Upload & selection state
const [uploadType, setUploadType] = useState(null); // 0: file, 1: selection
const [fileName, setFileName] = useState('');
@@ -120,6 +137,13 @@ export default function MultipleOrders() {
queryFn: getAdminPricing
});
const { data: hubList = [] } = useHubs();
const flowAnchors = useMemo(
() => buildAnchors({ hubs: hubList, tenantLocations }),
[hubList, tenantLocations]
);
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
queryKey: ['adminCustomers'],
queryFn: getAdminCustomers,
@@ -169,11 +193,24 @@ export default function MultipleOrders() {
};
// Distance calculation helper
// The collection point for ONE row.
//
// `__pickup` is attached per row during processing when the run collects from
// customers; it travels on the row rather than on the page so a file can mix
// origins without one row silently re-addressing another. Falls back to the
// shared business location, which is every row's answer on a hub run.
const rowPickup = (customer) => rowPickupOf(customer, pickCust);
const calculateDistance = async (customer) => {
if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.');
if (!pickCust || typeof pickCust !== 'object') throw new Error('Pickup location missing.');
const origin = rowPickup(customer);
if (!origin || typeof origin !== 'object') throw new Error('Pickup location missing.');
const roundedDistance = await calculateDrivingDistance(pickCust, customer);
// Measured from THIS row's collection point. Measuring every row from the
// hub while collecting at a doorstep prices the wrong leg entirely — for a
// collection near the recipient it bills a cross-town run that no rider
// makes, and for one far from both it under-bills.
const roundedDistance = await calculateDrivingDistance(origin, customer);
const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm);
return { roundedDistance, totalcharge };
};
@@ -275,6 +312,12 @@ export default function MultipleOrders() {
if (!users.length) return;
setIsProcessingRows(true);
const newDrops = [];
// One lookup per DISTINCT sender address, not per row. A real tenant file
// is three home kitchens across thirteen drops — geocoding the same kitchen
// six times costs six rate-limited round trips for one answer, and risks
// the six rows landing on marginally different coordinates, which would
// then draw six collection pins where the operator expects one.
const senderPlaceCache = new Map();
// Set once the first address lookup has gone out, so the pause below is
// paid between lookups rather than before the first one.
let geocoded = false;
@@ -296,14 +339,56 @@ export default function MultipleOrders() {
[customer.address, customer.city, customer.postcode].filter(Boolean).join(', '),
{ bias: pickCust?.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined }
);
if (place?.geometry?.location) {
lat = place.geometry.location.lat();
lng = place.geometry.location.lng();
if (place) {
lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : null);
lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : null);
}
}
// The row's own collection point, from the sheet's sender columns.
// Geocoded the same way the delivery address is — a pickup without
// coordinates is unroutable, and a row that cannot produce them keeps
// the shared hub rather than being booked at 0,0.
let rowOwnPickup = null;
if (isCustomerPickup) {
const senderAddress = String(customer.locationaddress || customer.senderaddress || '').trim();
if (senderAddress) {
if (!senderPlaceCache.has(senderAddress)) {
if (geocoded) await new Promise((resolve) => setTimeout(resolve, GEOCODE_INTERVAL_MS));
geocoded = true;
senderPlaceCache.set(
senderAddress,
await geocodeAddress(senderAddress, {
bias: pickCust?.latitude
? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) }
: undefined
})
);
}
const senderPlace = senderPlaceCache.get(senderAddress);
const sLat = senderPlace?.latitude ?? senderPlace?.geometry?.location?.lat?.();
const sLng = senderPlace?.longitude ?? senderPlace?.geometry?.location?.lng?.();
if (sLat && sLng) {
rowOwnPickup = {
locationname: customer.locationname || 'Customer Pickup',
contactno: String(customer.locationcontact || '').replace(/\D/g, '').slice(0, 10),
address: senderPlace.formatted_address || senderAddress,
city: senderPlace.city || '',
pincode: senderPlace.postcode || '',
latitude: sLat,
longitude: sLng
};
}
}
}
const enrichedCustomer = {
...customer,
__pickup: rowOwnPickup,
// Surfaced on the row so an operator reviewing the preview can see
// which rows fell back to the hub because their sender address would
// not geocode, instead of discovering it after 200 orders are live.
__pickupFellBack: isCustomerPickup && !rowOwnPickup,
latitude: lat || null,
longitude: lng || null,
quantity: Number(customer.quantity) || 1,
@@ -323,6 +408,19 @@ export default function MultipleOrders() {
setDropCust(newDrops);
setIsProcessingRows(false);
const fellBack = newDrops.filter((d) => d.__pickupFellBack).length;
if (fellBack > 0) {
// Named, not silent. These rows will be collected from the hub, which is
// a different journey from the one the sheet asked for.
OpenToast(
`${fellBack} of ${newDrops.length} rows had no usable Sender Address — those will be collected from ${
pickCust?.locationname || 'the selected hub'
}`,
'warning',
6000
);
}
OpenToast(`Processed ${newDrops.length} delivery drops`, 'success', 2500);
};
@@ -448,32 +546,20 @@ export default function MultipleOrders() {
'YYYY-MM-DD HH:mm:ss'
);
const bookings = dropCust.map((customer) => ({
tenantid: Number(selectedTenantId),
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.pincode || pickCust.postcode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0,
customer_phone: customer.contactno?.toString() || '',
customer_name: customer.firstname || '',
deliveryaddress: customer.address || '',
deliverypincode: customer.postcode?.toString() || '',
deliverycity: customer.city || '',
deliverylatitude: Number(customer.latitude) || 0,
deliverylongitude: Number(customer.longitude) || 0,
// The transform lives in lib/bulkOrderPayload so a test can drive the real
// thing against a real tenant file, rather than a copy of it that agrees
// today. Per row, never collapsed onto one shared origin: a run can mix a
// doorstep collection with a hub pickup that fell back, and writing the hub
// across all of them would silently re-address half the file.
const bookings = buildBulkBookingPayloads({
rows: dropCust,
sharedPickup: pickCust,
tenantId: selectedTenantId,
tenantLocationId: selectedLocationId,
deliverytime,
service_option: 'Normal',
finalprice: Number(customer.totalcharge) || 0,
notes: otherInstructions || '',
parcels: [
{
itemcategory: 'General',
itemdescription: 'Order',
declaredvalue: Number(customer.totalcharge) || 0
}
]
}));
notes: otherInstructions,
anchors: flowAnchors
});
setIsSubmitting(true);
try {
@@ -625,6 +711,56 @@ export default function MultipleOrders() {
/>
</div>
{/* Where this run collects from. The hub stays selected either
way — it is the servicing location the orders belong to, and
the fallback for any row whose sender address will not
geocode — but under 'customer' it stops being the address
written onto every booking. */}
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-2.5">
<p className="text-[11px] font-semibold text-slate-700 mb-2">Collect from</p>
<div className="grid grid-cols-2 gap-2">
{[
{ mode: PICKUP_SOURCE.HUB, title: 'Business Hub', hint: 'One shared origin' },
{ mode: PICKUP_SOURCE.CUSTOMER, title: 'Customer Pickup', hint: "Each row's sender address" }
].map(({ mode, title, hint }) => {
const active = pickupMode === mode;
return (
<button
key={mode}
type="button"
aria-pressed={active}
onClick={() => {
if (mode === pickupMode) return;
setPickupMode(mode);
// The drops already carry resolved origins, distances
// and prices computed against the OLD mode. Keeping
// them would show a priced, ready-to-dispatch list
// that silently disagrees with the switch just made.
setDropCust([]);
}}
className={`rounded-lg border px-2.5 py-2 text-left transition-colors cursor-pointer ${
active
? 'border-black bg-white shadow-2xs ring-2 ring-black/10'
: 'border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white'
}`}
>
<span className={`block text-xs font-bold ${active ? 'text-slate-900' : 'text-slate-600'}`}>
{title}
</span>
<span className="block text-[10px] text-slate-500 leading-tight mt-0.5">{hint}</span>
</button>
);
})}
</div>
{isCustomerPickup && (
<p className="mt-2 text-[10px] text-violet-700 bg-violet-50 border border-violet-200 rounded-lg px-2.5 py-1.5 leading-relaxed">
Each row is collected from its <strong>Sender Address</strong> column. Rows without a usable
one fall back to the hub above, and you will be told how many.
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">Pickup Date</label>
@@ -661,6 +797,13 @@ export default function MultipleOrders() {
<div className="min-w-0 flex-1">
<div className="font-bold truncate">{pickCust.locationname || 'Pickup Hub'}</div>
<div className="text-[11px] text-sky-800 line-clamp-1 mt-0.5">{pickCust.address || '—'}</div>
{/* This tile reads as "everything is collected here", so it
has to say when that is no longer true. */}
{isCustomerPickup && (
<div className="text-[10px] font-semibold text-sky-700 mt-1">
Servicing hub only — rows are collected from their own sender addresses
</div>
)}
</div>
</div>
) : (
@@ -887,6 +1030,14 @@ export default function MultipleOrders() {
<tr>
<th className="py-3 pl-4 pr-2 w-8">#</th>
<th className="py-3 px-3 w-40">Customer</th>
{/* Only in customer-pickup mode, and deliberately BEFORE the
delivery address: a geocoded sender line is the one thing
on this screen the operator has not seen with their own
eyes. A coarse "Edayarpalayam, Coimbatore" resolves to a
landmark in that locality, not the kitchen, and a rider
sent there arrives at the wrong door. Better caught here
than after thirteen orders are live. */}
{isCustomerPickup && <th className="py-3 px-3">Collect from</th>}
<th className="py-3 px-3">Address</th>
<th className="py-3 px-2 w-16 text-center">Qty</th>
<th className="py-3 px-2 w-24 text-center">Cash (₹)</th>
@@ -905,6 +1056,30 @@ export default function MultipleOrders() {
<div className="font-bold text-slate-900 text-xs truncate max-w-[150px]">{row.firstname || '—'}</div>
<div className="text-[11px] text-slate-600 font-mono font-medium mt-0.5">{row.contactno || '—'}</div>
</td>
{isCustomerPickup && (
<td className="py-3 px-3 min-w-[180px]">
{row.__pickup ? (
<>
<div
className="truncate max-w-[180px] text-[11px] font-semibold text-violet-800"
title={row.__pickup.address}
>
{row.__pickup.locationname || 'Customer Pickup'}
</div>
<div
className="truncate max-w-[180px] text-[10px] text-slate-500 mt-0.5"
title={row.__pickup.address}
>
{row.__pickup.address}
</div>
</>
) : (
<div className="text-[10px] font-semibold text-amber-700" title="Sender address could not be resolved">
Fell back to hub
</div>
)}
</td>
)}
<td className="py-3 px-3 min-w-[220px]">
{needsAddress ? (
<AddressAutocomplete

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react';
import { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers, useRiderOptions } from '@/lib/doormileHooks';
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { createAutomationDeliveries } from '@/api/doormile';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
@@ -12,8 +12,11 @@ import { drainBookings } from '@/lib/assistant/scan';
import { OpenToast, messageOf } from '@/api/doormile/notify';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
import { FLOW, classifyBooking } from '@/lib/orderFlow';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { dropSummary, isMultiDrop } from '@/lib/bookingDrops';
import { BookingDestinationsDrawer } from './BookingDestinationsDrawer';
/**
* Orders — every booking, grouped by where it sits in the operator's workflow.
@@ -82,6 +85,9 @@ export default function Orders() {
const debouncedSearch = useDebouncedValue(search);
const [selectedIds, setSelectedIds] = useState([]);
const [cancelOpen, setCancelOpen] = useState(false);
/* The row whose drops are open. Holds the row itself, not just the id, so the
drawer can title itself from `bookingno` without waiting for the fetch. */
const [dropsRow, setDropsRow] = useState(null);
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
@@ -165,6 +171,12 @@ export default function Orders() {
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
/* Which end of each booking is a facility. This page reads RAW bookings
rather than the deliveries feed, so it has to classify for itself — but
against the same shared anchor list, so a row cannot read "customer
pickup" here and "hub run" on Dispatch. */
const { data: flowAnchors = [] } = useFlowAnchors((bookings || []).map((b) => b?.tenantid));
const rows = useMemo(
() =>
(zoneBookings || [])
@@ -315,17 +327,37 @@ export default function Orders() {
customer?.firstname ||
customer?.name ||
(row.appcustomerid ? `Customer #${row.appcustomerid}` : 'Drop');
/* "Pickup" alone reads as "collected from our hub", which is what it
always meant. Once it can also mean a doorstep, the row has to say
which — otherwise two very different journeys look identical in the
list. Silent on an ordinary hub run, so the label only appears
where it carries information. */
const flow = classifyBooking(row, flowAnchors);
const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
return (
<div className="min-w-0">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
<span className="font-medium text-ink-1">Pickup</span>
<span className={isCustomerPickup ? 'font-medium text-violet-700' : 'font-medium text-ink-1'}>
{isCustomerPickup ? 'Customer pickup' : 'Pickup'}
</span>
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
</p>
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
<span className="font-medium text-ink-2">{dropName}</span>
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
</p>
{/* Collapsed, a multi-drop row reads as ONE pickup request that
created several shipments — never as one of them. The flat
`deliveryaddress` it would otherwise show is destination 0
mirrored by CreateCxBooking, so on a three-drop pickup it is
Chennai standing in for all three. Open the row for the legs. */}
{isMultiDrop(row) ? (
<p className="truncate text-caption text-ink-3">
<span className="font-medium text-violet-700">{dropSummary(row)}</span>
</p>
) : (
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
<span className="font-medium text-ink-2">{dropName}</span>
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
</p>
)}
</div>
);
},
@@ -361,6 +393,21 @@ export default function Orders() {
return <StatusBadge status="arrived" dot size="sm" />;
}
if (rawStatus === 'converted_to_consignment') {
/* THE ONE THAT WOULD SILENTLY LIE.
`pickupbookings.consignmentid` names the FIRST leg only — set
that way at pickup-complete so single-drop screens keep working
— and `consignmentstatus` is joined from it. On a three-drop
pickup that is Chennai's status wearing the whole booking's
name, and rendering it here would confidently report "Delivered"
while two parcels are still in transit.
A multi-drop row therefore shows the PICKUP's own state, which
is genuinely finished once the rider has collected. Per-leg
status lives in the drawer, read from each destination's own
stage. */
if (isMultiDrop(row)) {
return <StatusBadge status="picked" dot size="sm" />;
}
const cStatus = row.consignmentstatus ?? row.consignment_status;
return <StatusBadge status={cStatus || 'picked'} dot size="sm" />;
}
@@ -476,6 +523,18 @@ export default function Orders() {
{!isAllZones && (
<span className="font-medium text-ink-2"> · Zone: {selectedZone.hubname}</span>
)}
{/* Search runs over the flat booking columns, and `deliveryaddress`
is destination 0 mirrored by CreateCxBooking. Searching
"Ernakulam" on a three-drop pickup whose first drop is Chennai
therefore finds nothing. The list has no destination array to
index — it carries only the counts, deliberately — so the honest
fix is to say which drop is searched rather than to fail
silently. */}
{debouncedSearch && (
<span className="text-ink-3">
{' '}· search covers the order number, pickup, notes and the first drop only
</span>
)}
{/* A capped scan means an order really can be missing from this tab.
Say so — a silently short list is what sent operators hunting for
an order they had just created. */}
@@ -493,6 +552,12 @@ export default function Orders() {
columns={columns}
rows={rows}
getRowId={(row) => row.bookingid}
/* Opens the drops for ANY booking, not only multi-drop ones: the
per-destination recipient, instructions and COD exist nowhere else
in the console, and a row that is clickable only sometimes is worse
than one that always is. The checkbox cell stops propagation, so
this does not fight selection. */
onRowClick={(row) => setDropsRow(row)}
loading={isLoading}
refreshing={isFetching && !isLoading}
selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
@@ -630,6 +695,15 @@ export default function Orders() {
})
}
/>
<BookingDestinationsDrawer
open={Boolean(dropsRow)}
onOpenChange={(next) => {
if (!next) setDropsRow(null);
}}
bookingId={dropsRow?.bookingid}
bookingNo={dropsRow?.bookingno || (dropsRow ? `#${dropsRow.bookingid}` : undefined)}
/>
</Stack>
);
}