updates on the bookings page and updated ai and more thngs
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
350
src/components/doormile/MapPinPickerModal.jsx
Normal file
350
src/components/doormile/MapPinPickerModal.jsx
Normal 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='© <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
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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
54
src/lib/bookingDrops.js
Normal 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
120
src/lib/bulkOrderPayload.js
Normal 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 }));
|
||||
110
src/lib/customerAppBookings.js
Normal file
110
src/lib/customerAppBookings.js
Normal 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));
|
||||
};
|
||||
@@ -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
352
src/lib/geocodingService.js
Normal 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
62
src/lib/hubForm.js
Normal 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
567
src/lib/orderFlow.js
Normal 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
78
src/lib/routingSummary.js
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -7,5 +7,5 @@ import '@/index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
592
src/pages/doormile/bookings/Bookings.jsx
Normal file
592
src/pages/doormile/bookings/Bookings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ''}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? '↑' : (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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
194
src/pages/doormile/orders/BookingDestinationsDrawer.jsx
Normal file
194
src/pages/doormile/orders/BookingDestinationsDrawer.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user