updates on the bookings page and updated ai and more thngs

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

View File

@@ -10,3 +10,8 @@
# OSRM routing, used to price an order by real driving distance. Falls back to
# the public demo server, which is rate-limited and not for production traffic.
VITE_OSRM_URL=https://router.project-osrm.org
# Geocoding & Address Suggestions
# The console uses high-speed Photon & OSM by default (no key required).
# To enable Ola Maps Places Autocomplete for pinpoint Indian addresses, provide an API key:
# VITE_OLA_MAPS_API_KEY=your_ola_maps_api_key_here

View File

@@ -0,0 +1,215 @@
/**
* Build the logistics bulk-upload test sheet.
*
* node scripts/makeLogisticsTestSheet.cjs
*
* The tenant's own export (tests/fixtures/jayanthi-kitchen-route.xls) is 13
* lunch drops inside Coimbatore. Every one of them is hyperlocal, so uploading
* it exercises none of the base-handover path — no parcel in that file is ever
* routed through a base, and `next_action` never leaves `deliver`.
*
* This sheet keeps that file's exact column titles, so it travels through the
* same header map and the same page code, and changes only the one thing that
* decides routing: WHERE THE PARCEL IS GOING.
*
* The rule it is built against (backend, isHyperlocalBooking):
*
* • both pincodes present → same first 3 digits is hyperlocal, different is
* routed through a base. The prefix decides
* outright; distance does not get a vote.
* • a pincode missing → fall back to straight-line distance, ≤30 km is
* hyperlocal.
* • neither resolvable → routed through a base, which is the safe answer.
*
* Two columns are added that the tenant's file does not carry — Receiver
* Pincode and Receiver City. Both are already in SHEET_HEADER_MAP
* (receiverpincode → postcode, receivercity → city), so this is not a new
* format: it is the same format with columns the map already knows filled in.
* Without a pincode every row would land on the distance fallback, and the
* prefix rule — the one that actually decides intercity — would never run.
*/
const fs = require('node:fs');
const path = require('node:path');
const XLSX = require('xlsx');
const SENDER = {
name: 'Jayanthi',
phone: 7708733769,
address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025'
};
const PICKUP_DATE = 'Sep 02, 2026';
/**
* expect: what the backend should decide for the row, and why. Not a column —
* it is carried here so the fixture and the test assertions cannot drift, and
* it is written into a "Notes" column on the sheet so an operator running this
* by hand can see what they are meant to be looking at.
*/
const ROWS = [
// ---- Customer → Base. Different postal area, so the rider collects and
// carries it to a base instead of to the receiver. This is the whole
// point of the file.
{
name: 'Suresh Kumar', phone: 9840112233, alt: 9840112234,
address: 'Flat 3B, 21 NSC Bose Road, Parrys, Chennai', city: 'Chennai', pincode: '600001',
lat: 13.091, lng: 80.285, item: 'Documents envelope', qty: 1, cash: 0,
expect: 'base handover — Coimbatore 641 to Chennai 600'
},
{
name: 'Priya Raghavan', phone: 9840223344, alt: 9840223345,
address: '14 Cheran Street, Raja Annamalai Puram, Chennai', city: 'Chennai', pincode: '600028',
lat: 13.018, lng: 80.256, item: 'Spare parts carton', qty: 2, cash: 0,
expect: 'base handover — Coimbatore 641 to Chennai 600'
},
{
name: 'Anil Reddy', phone: 9848334455, alt: 9848334456,
address: 'Plot 42, Gachibowli, Hyderabad', city: 'Hyderabad', pincode: '500081',
lat: 17.44, lng: 78.3489, item: 'Textile samples', qty: 3, cash: 0,
expect: 'base handover — interstate, Coimbatore to Hyderabad'
},
{
name: 'Meera Krishnan', phone: 9848445566, alt: 9848445567,
address: 'Tower B, Financial District, Nanakramguda, Hyderabad', city: 'Hyderabad', pincode: '500032',
lat: 17.4156, lng: 78.3378, item: 'Legal documents', qty: 1, cash: 450,
expect: 'base handover with COD — cash must survive the base leg'
},
{
name: 'Rahul Menon', phone: 9880556677, alt: 9880556678,
address: '9 Brigade Road, MG Road, Bengaluru', city: 'Bengaluru', pincode: '560001',
lat: 12.975, lng: 77.606, item: 'Electronics accessories', qty: 2, cash: 0,
expect: 'base handover — interstate, Coimbatore to Bengaluru'
},
{
name: 'Divya Nair', phone: 9880667788, alt: 9880667789,
address: 'Prestige Shantiniketan, Whitefield, Bengaluru', city: 'Bengaluru', pincode: '560066',
lat: 12.9698, lng: 77.75, item: 'Gift hamper', qty: 1, cash: 1200,
expect: 'base handover with COD'
},
{
name: 'Karthik Subramani', phone: 9843778899, alt: 9843778890,
address: '5 East Veli Street, Madurai', city: 'Madurai', pincode: '625001',
lat: 9.9195, lng: 78.119, item: 'Temple prasadam box', qty: 1, cash: 0,
expect: 'base handover — same state, different postal area still routes via a base'
},
{
name: 'Lakshmi Devi', phone: 9842889900, alt: 9842889901,
address: '18 Cherry Road, Salem', city: 'Salem', pincode: '636001',
lat: 11.664, lng: 78.146, item: 'Handloom bundle', qty: 2, cash: 0,
expect: 'base handover — same state, different postal area'
},
// ---- Customer → Customer. Same postal area, so the collecting rider carries
// it straight to the receiver and no base is involved. These rows are
// the control group: if one of them routes to a base, the prefix rule
// has broken.
{
name: 'Ganesh Iyer', phone: 9994001122, alt: 9994001123,
address: '22 Avinashi Road, Peelamedu, Coimbatore', city: 'Coimbatore', pincode: '641004',
lat: 11.029, lng: 76.993, item: 'Lunch box', qty: 1, cash: 0,
expect: 'direct to customer — same 641 area'
},
{
name: 'Revathi Balaji', phone: 9994112233, alt: 9994112234,
address: '7 Cross Cut Road, Gandhipuram, Coimbatore', city: 'Coimbatore', pincode: '641012',
lat: 11.018, lng: 76.966, item: 'Lunch box', qty: 2, cash: 260,
expect: 'direct to customer with COD — same 641 area'
},
{
name: 'Vignesh Murugan', phone: 9994223344, alt: 9994223345,
address: '3/74 Nethaji Street, Edayarpalayam, Coimbatore', city: 'Coimbatore', pincode: '641025',
lat: 11.008, lng: 76.928, item: 'Lunch box', qty: 1, cash: 0,
expect: 'direct to customer — receiver shares the sender pincode'
},
{
name: 'Anitha Selvam', phone: 9994334455, alt: 9994334456,
address: '11 DB Road, Saibaba Colony, Coimbatore', city: 'Coimbatore', pincode: '641038',
lat: 11.023, lng: 76.945, item: 'Bakery order', qty: 3, cash: 0,
expect: 'direct to customer — same 641 area'
},
// ---- The fallback rule. A pincode is missing, so the decision falls to the
// straight-line distance instead. Both outcomes are represented, because
// a fallback that only ever answers one way is not being tested.
{
name: 'Mohan Das', phone: 9995445566, alt: 9995445567,
address: 'Near Hope College signal, Coimbatore', city: 'Coimbatore', pincode: '',
lat: 11.05, lng: 77.01, item: 'Stationery pack', qty: 1, cash: 0,
expect: 'direct to customer — no pincode, but ~8 km falls inside the 30 km fallback'
},
{
name: 'Sridhar Venkat', phone: 9995556677, alt: 9995556678,
address: 'Opposite Central Station, Chennai', city: 'Chennai', pincode: '',
lat: 13.0827, lng: 80.2707, item: 'Machine component', qty: 1, cash: 0,
expect: 'base handover — no pincode, but ~430 km is far outside the fallback'
},
{
name: 'Bhavani Shankar', phone: 9995667788, alt: 9995667789,
address: 'T Nagar, Chennai', city: 'Chennai', pincode: '64',
lat: 13.06, lng: 80.24, item: 'Fabric roll', qty: 1, cash: 0,
expect: 'base handover — a 2-digit pincode is unusable, so distance decides'
},
// ---- The case that proves the prefix rule outranks distance. Pollachi is
// ~40 km from Coimbatore — closer than plenty of runs the console treats
// as local — but 642 is a different postal area, so it goes via a base.
{
name: 'Ramesh Palanisamy', phone: 9995778899, alt: 9995778890,
address: '2 Mahalingapuram Road, Pollachi', city: 'Pollachi', pincode: '642001',
lat: 10.658, lng: 77.008, item: 'Agricultural samples', qty: 2, cash: 0,
expect: 'base handover — nearby, but 642 is a different area and the prefix decides'
}
];
const HEADERS = [
'Pickup Date (YYYY-MMM-DD )',
'Sender Name*',
'Sender Phone*',
'Sender Address*',
'Receiver Name*',
'Receiver Phone',
'Receiver Alternate Phone*',
'Receiver Full Address',
'Receiver City',
'Receiver Pincode',
'Receiver Latitude',
'Receiver Longitude',
'Item Description*',
'Quantity',
'Collect Cash',
'Expected Routing (not read by the app)'
];
const sheetRows = ROWS.map((r) => ({
'Pickup Date (YYYY-MMM-DD )': PICKUP_DATE,
'Sender Name*': SENDER.name,
'Sender Phone*': SENDER.phone,
'Sender Address*': SENDER.address,
'Receiver Name*': r.name,
'Receiver Phone': r.phone,
'Receiver Alternate Phone*': r.alt,
'Receiver Full Address': r.address,
'Receiver City': r.city,
'Receiver Pincode': r.pincode,
'Receiver Latitude': r.lat,
'Receiver Longitude': r.lng,
'Item Description*': r.item,
Quantity: r.qty,
'Collect Cash': r.cash,
'Expected Routing (not read by the app)': r.expect
}));
const ws = XLSX.utils.json_to_sheet(sheetRows, { header: HEADERS });
ws['!cols'] = HEADERS.map((h) => ({ wch: Math.min(46, Math.max(14, h.length + 2)) }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Doormile_Logistics_Test');
const targets = process.argv.slice(2);
if (targets.length === 0) targets.push(path.join(__dirname, '..', 'tests', 'fixtures', 'doormile-logistics-test.xlsx'));
targets.forEach((out) => {
fs.mkdirSync(path.dirname(out), { recursive: true });
XLSX.writeFile(wb, out);
console.log(`wrote ${sheetRows.length} rows → ${out}`);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,459 @@
import fs from 'node:fs';
import path from 'node:path';
import * as XLSX from 'xlsx';
import {
SHEET_HEADER_MAP,
cleanReceiverName,
normalizeHeader,
requiredSheetColumns
} from '@/lib/bulkOrderColumns';
import { buildBulkBookingPayloads, buildParcels } from '@/lib/bulkOrderPayload';
import { FLOW, buildAnchors, classifyBooking } from '@/lib/orderFlow';
import { calculateTotalCharge } from '@/lib/distance';
/**
* Bulk upload, end to end, against a REAL tenant file.
*
* `tests/fixtures/jayanthi-kitchen-route.xls` is an actual export a tenant
* uploaded: 13 lunch deliveries around Coimbatore, leaving from THREE different
* home kitchens — Jayanthi (6 drops), Latha (3), Manjusha (4).
*
* That file is the whole reason this suite exists. The page's bulk path was
* built on the assumption that one upload has one origin: it parsed the sheet's
* Sender columns and then overwrote every row's pickup with the single business
* location the operator had selected. Uploading this file therefore booked all
* 13 orders against one kitchen and silently erased the other two — the rider
* would be sent to Edayarpalayam for parcels sitting in Kavundampalayam.
*
* These tests drive the REAL modules the page imports — the real header map,
* the real payload builder, the real flow classifier. Nothing is reimplemented
* here, so a passing test is a statement about what the page does rather than
* about a copy of it that happens to agree today.
*
* The one thing deliberately NOT exercised is network geocoding. The fixture
* carries Receiver Latitude/Longitude for every row, so drops need no lookup;
* sender coordinates are injected from a stub table below, standing in for what
* the geocoder returns. That keeps the suite hermetic and deterministic while
* still covering every transformation that decides what goes on the wire.
*/
const FIXTURE = path.join(__dirname, '..', 'fixtures', 'jayanthi-kitchen-route.xls');
// The tenant's selected business location — one origin, as the page has always
// assumed. Coordinates are Jayanthi's kitchen.
const BUSINESS_LOCATION = {
tenantlocationid: 31,
tenantid: 4,
hubid: 7,
locationname: 'Jayanthi Kitchen',
address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025',
city: 'Coimbatore',
pincode: '641025',
latitude: 11.0168,
longitude: 76.9558
};
const DOORMILE_HUB = {
hubid: 7,
hubname: 'Coimbatore Gandhipuram Hub',
latitude: 11.0168,
longitude: 76.9558
};
/* Stands in for the geocoder. Keyed by the sheet's exact Sender Address text —
the three kitchens the file actually contains. */
const SENDER_COORDS = {
'Edayarpalayam, Coimbatore, Tamil Nadu 641025': { lat: 11.0168, lng: 76.9558 },
'Kavundampalayam, 641030': { lat: 11.0447, lng: 76.9382 },
'Ramakrishnamill, Ganapathy, Coimbatore - 641006': { lat: 11.0296, lng: 76.9891 }
};
const RATE = { basePrice: 40, pricePerKm: 8, minKm: 5 };
const DELIVERY_TIME = '2026-09-01 09:00:00';
// ---- the page's own parse, reproduced from MultipleOrders.processData -------
// The mapping table and normalizer are imported, not copied; only the loop that
// walks them is restated, because it lives inside a component.
const parseFixture = () => {
const wb = XLSX.read(fs.readFileSync(FIXTURE), { type: 'buffer' });
const sheet = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json(sheet, { defval: '' });
const normalizedMap = {};
for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key];
const rows = raw.map((row, idx) => {
const out = { _rowkey: `csv-${idx}`, quantity: 1, collectionamt: 0 };
for (const key in row) {
const clean = normalizeHeader(key);
const mapped = normalizedMap[clean] || clean;
out[mapped] = mapped === 'firstname' ? cleanReceiverName(row[key]) : row[key];
}
return out;
});
return { raw, rows, headers: Object.keys(raw[0]).map(normalizeHeader) };
};
/** Straight-line km, standing in for the OSRM call the page makes. Only used to
* give each row a plausible price so the payload has a real number in it. */
const km = (a, b) => {
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lng - a.lng);
const h =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return Math.round(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
};
/** Rows as the page holds them after processing: coordinates resolved, price
* computed, and `__pickup` attached when the run collects from customers. */
const processRows = (rows, { customerPickup }) =>
rows.map((row) => {
const senderAddress = String(row.locationaddress || '').trim();
const coords = SENDER_COORDS[senderAddress];
const pickup =
customerPickup && coords
? {
locationname: row.locationname || 'Customer Pickup',
contactno: String(row.locationcontact || '').replace(/\D/g, '').slice(0, 10),
address: senderAddress,
city: '',
pincode: '',
latitude: coords.lat,
longitude: coords.lng
}
: null;
const origin = pickup || BUSINESS_LOCATION;
const distance = km(
{ lat: Number(origin.latitude), lng: Number(origin.longitude) },
{ lat: Number(row.latitude), lng: Number(row.longitude) }
);
return {
...row,
__pickup: pickup,
__pickupFellBack: customerPickup && !pickup,
distance,
totalcharge: calculateTotalCharge(distance, RATE.basePrice, RATE.pricePerKm, RATE.minKm)
};
});
const anchors = buildAnchors({ hubs: [DOORMILE_HUB], tenantLocations: [BUSINESS_LOCATION] });
const payloadsFor = (mode) =>
buildBulkBookingPayloads({
rows: processRows(parseFixture().rows, { customerPickup: mode === 'customer' }),
sharedPickup: BUSINESS_LOCATION,
tenantId: 4,
tenantLocationId: 31,
deliverytime: DELIVERY_TIME,
notes: 'Lunch run',
anchors
});
describe('bulk upload — Jayanthi_Kitchen_Route.xls', () => {
describe('the file itself', () => {
it('should be a single sheet of 13 delivery rows', () => {
const { raw } = parseFixture();
expect(raw).toHaveLength(13);
});
it('should carry three distinct sender kitchens, not one', () => {
// The premise of the whole feature. If this ever collapses to one, the
// fixture has been replaced and these tests no longer prove anything.
const { rows } = parseFixture();
const senders = new Set(rows.map((r) => r.locationaddress));
expect(senders.size).toBe(3);
expect([...senders].sort()).toEqual([
'Edayarpalayam, Coimbatore, Tamil Nadu 641025',
'Kavundampalayam, 641030',
'Ramakrishnamill, Ganapathy, Coimbatore - 641006'
]);
});
it('should split as 6 / 3 / 4 across the three kitchens', () => {
const { rows } = parseFixture();
const counts = rows.reduce((acc, r) => {
acc[r.locationname] = (acc[r.locationname] || 0) + 1;
return acc;
}, {});
expect(counts).toEqual({ Jayanthi: 6, Latha: 3, Manjusha: 4 });
});
});
describe('header mapping', () => {
it('should map every sheet column the app knows about', () => {
const { raw } = parseFixture();
const normalizedMap = {};
for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key];
const unmapped = Object.keys(raw[0]).filter((h) => !normalizedMap[normalizeHeader(h)]);
// "Address id" is the tenant's own upstream record id. It is carried
// through unmapped and unused, which is correct — it means nothing here.
expect(unmapped).toEqual(['Address id']);
});
it('should tolerate the whitespace and casing the real header row carries', () => {
// The live header is "Pickup Date (YYYY-MMM-DD )" — note the space before
// the bracket. normalizeHeader strips ALL whitespace, which is the only
// reason this column resolves; a mapping keyed on the literal string
// would miss it.
expect(normalizeHeader('Pickup Date (YYYY-MMM-DD )')).toBe('pickupdate(yyyy-mmm-dd)');
expect(SHEET_HEADER_MAP['pickupdate(yyyy-mmm-dd)']).toBe('date');
});
it('should report no missing required columns', () => {
const { headers } = parseFixture();
const missing = requiredSheetColumns().filter((c) => !headers.includes(normalizeHeader(c)));
expect(missing).toEqual([]);
});
it('should give every row a delivery address and usable coordinates', () => {
const { rows } = parseFixture();
rows.forEach((row) => {
expect(String(row.address || '').trim()).not.toBe('');
expect(Number(row.latitude)).toBeGreaterThan(10);
expect(Number(row.longitude)).toBeGreaterThan(76);
});
});
it('should leave delivery city and pincode empty, because the sheet has no such columns', () => {
// Documented rather than asserted-away. bulkOrderColumns maps
// `receivercity`/`receiverpincode` speculatively — this real file proves
// the tenant does not send them, so deliverycity/deliverypincode go to the
// backend blank on every row of every upload from this tenant.
const { rows } = parseFixture();
expect(rows.every((r) => !r.city)).toBe(true);
expect(rows.every((r) => !r.postcode)).toBe(true);
});
});
describe('parcels — quantity and description', () => {
it('should expand quantity into one parcel entry per item', () => {
// Not cosmetic: the app reads quantity back as `parcels.length`
// everywhere downstream, so a 5-item order collapsed to one entry tells
// the rider to collect one bag.
expect(buildParcels({ quantity: 5, description: 'Low GI Carb Meal' })).toHaveLength(5);
expect(buildParcels({ quantity: 1 })).toHaveLength(1);
});
it('should never emit zero parcels, whatever the sheet says', () => {
[0, -3, '', null, undefined, 'abc', 1.4].forEach((quantity) => {
expect(buildParcels({ quantity }).length).toBeGreaterThanOrEqual(1);
});
});
it("should carry the sheet's own item description, not a placeholder", () => {
const [parcel] = buildParcels({ quantity: 1, description: 'Economy Lunch Lunch - Jayanthi-Delivery' });
expect(parcel.itemdescription).toBe('Economy Lunch Lunch - Jayanthi-Delivery');
});
it('should fall back to a generic description only when the sheet gives none', () => {
expect(buildParcels({ quantity: 1, description: ' ' })[0].itemdescription).toBe('Order');
});
it('should produce 19 parcels across the 13 rows of the real file', () => {
// 2+1+1+2+1+1+1+5+1+1+1+1+1 = 19. Before quantity was honoured this was 13.
const total = payloadsFor('hub').reduce((sum, b) => sum + b.parcels.length, 0);
expect(total).toBe(19);
});
});
describe('hub mode — every row collected from the one selected location', () => {
const payloads = payloadsFor('hub');
it('should produce one booking per sheet row', () => {
expect(payloads).toHaveLength(13);
});
it('should write the business location as the pickup on every row', () => {
payloads.forEach((b) => {
expect(b.pickupaddress).toBe(BUSINESS_LOCATION.address);
expect(b.pickuplatitude).toBe(BUSINESS_LOCATION.latitude);
expect(b.pickuplongitude).toBe(BUSINESS_LOCATION.longitude);
});
});
it('should classify every row as an ordinary hub run', () => {
payloads.forEach((b) => expect(b.ordertype).toBe(FLOW.FORWARD));
});
it('should carry each receiver through to the booking', () => {
expect(payloads[0].customer_name).toBe('Lalitha');
expect(payloads[0].customer_phone).toBe('9843174920');
expect(payloads[0].deliveryaddress).toContain('Lalitha boutique 36 Railway mens colony');
expect(payloads[0].deliverylatitude).toBeCloseTo(11.0506991, 6);
});
it('should send phone numbers as strings even though the sheet types them as numbers', () => {
// XLSX parses a phone cell as a Number. A number on the wire where the
// backend expects a string is the kind of thing that 400s the whole batch.
payloads.forEach((b) => {
expect(typeof b.customer_phone).toBe('string');
expect(b.customer_phone).toMatch(/^\d{10}$/);
});
});
it('should price every row and never send a zero or NaN charge', () => {
payloads.forEach((b) => {
expect(Number.isFinite(b.finalprice)).toBe(true);
expect(b.finalprice).toBeGreaterThanOrEqual(RATE.basePrice);
});
});
it('should stamp the servicing hub so the orders land in a zone', () => {
payloads.forEach((b) => {
expect(b.sourcehubid).toBe(7);
expect(b.hubid).toBe(7);
expect(b.tenantlocationid).toBe(31);
});
});
});
describe('customer-pickup mode — the case this file actually is', () => {
const payloads = payloadsFor('customer');
it('should give each row the pickup its OWN sender column names', () => {
// The defect this replaces: all 13 previously carried Jayanthi's address.
const byAddress = payloads.reduce((acc, b) => {
acc[b.pickupaddress] = (acc[b.pickupaddress] || 0) + 1;
return acc;
}, {});
expect(byAddress).toEqual({
'Edayarpalayam, Coimbatore, Tamil Nadu 641025': 6,
'Kavundampalayam, 641030': 3,
'Ramakrishnamill, Ganapathy, Coimbatore - 641006': 4
});
});
it('should not write one origin across rows that came from another kitchen', () => {
// Row 8 is Kishore, collected at Latha's kitchen in Kavundampalayam.
const kishore = payloads.find((b) => b.customer_name === 'Kishore');
expect(kishore.pickupaddress).toBe('Kavundampalayam, 641030');
expect(kishore.pickuplatitude).toBeCloseTo(11.0447, 4);
expect(kishore.pickuplatitude).not.toBe(BUSINESS_LOCATION.latitude);
});
it('should give every row real pickup coordinates, never 0,0', () => {
payloads.forEach((b) => {
expect(b.pickuplatitude).not.toBe(0);
expect(b.pickuplongitude).not.toBe(0);
});
});
it('should classify a kitchen that IS a registered location as a hub run, not a doorstep', () => {
// Jayanthi's kitchen is the tenant's registered business location. Rows
// leaving it are hub pickups whatever mode the operator chose — the flow
// follows the facts, not the toggle.
const jayanthi = payloads.filter(
(b) => b.pickupaddress === 'Edayarpalayam, Coimbatore, Tamil Nadu 641025'
);
expect(jayanthi).toHaveLength(6);
jayanthi.forEach((b) => expect(b.ordertype).toBe(FLOW.FORWARD));
});
it("should classify the two UNREGISTERED kitchens as point-to-point collections", () => {
const others = payloads.filter(
(b) => b.pickupaddress !== 'Edayarpalayam, Coimbatore, Tamil Nadu 641025'
);
expect(others).toHaveLength(7);
others.forEach((b) => expect(b.ordertype).toBe(FLOW.P2P));
});
it('should price each row from ITS OWN kitchen, not from the shared hub', () => {
// The pricing consequence. Measuring every row from one hub bills a
// cross-town leg no rider ever makes.
const hub = payloadsFor('hub');
const kishoreHub = hub.find((b) => b.customer_name === 'Kishore');
const kishoreOwn = payloads.find((b) => b.customer_name === 'Kishore');
expect(kishoreOwn.finalprice).not.toBe(kishoreHub.finalprice);
});
});
describe('collection points on the dispatch board', () => {
/* Bookings as the board would read them back after creation. */
const asBookings = payloadsFor('customer').map((b, i) => ({
bookingid: 1000 + i,
pickuplatitude: b.pickuplatitude,
pickuplongitude: b.pickuplongitude,
deliverylatitude: b.deliverylatitude,
deliverylongitude: b.deliverylongitude,
pickupaddress: b.pickupaddress
}));
it('should draw exactly three collection pins, not thirteen and not one', () => {
// One pin per real kitchen. Grouping per booking would stack six markers
// on Jayanthi's door; grouping by tenant name — what the console did
// before — would draw a single pin for all three.
const keys = new Set(asBookings.map((b) => classifyBooking(b, anchors).pickupPointKey));
expect(keys.size).toBe(3);
});
it('should group the six Jayanthi rows onto one pin', () => {
const jayanthiKeys = new Set(
asBookings
.filter((b) => b.pickupaddress === 'Edayarpalayam, Coimbatore, Tamil Nadu 641025')
.map((b) => classifyBooking(b, anchors).pickupPointKey)
);
expect(jayanthiKeys.size).toBe(1);
});
it('should keep the three kitchens on separate pins', () => {
const byAddress = new Map();
asBookings.forEach((b) => {
byAddress.set(b.pickupaddress, classifyBooking(b, anchors).pickupPointKey);
});
expect(new Set(byAddress.values()).size).toBe(3);
});
});
describe('regression guards', () => {
it('should never lose a row between sheet and wire', () => {
const { raw } = parseFixture();
expect(payloadsFor('hub')).toHaveLength(raw.length);
expect(payloadsFor('customer')).toHaveLength(raw.length);
});
it('should keep sheet order, so the preview an operator approved is what is sent', () => {
const { rows } = parseFixture();
const names = payloadsFor('customer').map((b) => b.customer_name);
expect(names).toEqual(rows.map((r) => r.firstname));
});
it('should put the same delivery time on every booking in one run', () => {
payloadsFor('hub').forEach((b) => expect(b.deliverytime).toBe(DELIVERY_TIME));
});
it('should apply the run-level note to every booking', () => {
payloadsFor('hub').forEach((b) => expect(b.notes).toBe('Lunch run'));
});
it('should send a numeric tenantid, not the string the form holds', () => {
payloadsFor('hub').forEach((b) => expect(b.tenantid).toBe(4));
});
it('should fall back to the shared hub when a sender address will not geocode', () => {
// The row is still booked — dropping it silently would be worse — but it
// is booked at the hub, and the page tells the operator how many did this.
const rows = processRows(parseFixture().rows, { customerPickup: true }).map((r) =>
r.customer_name === 'Kishore' ? r : { ...r, __pickup: null, __pickupFellBack: true }
);
const payloads = buildBulkBookingPayloads({
rows,
sharedPickup: BUSINESS_LOCATION,
tenantId: 4,
tenantLocationId: 31,
deliverytime: DELIVERY_TIME,
anchors
});
expect(payloads).toHaveLength(13);
payloads.forEach((b) => {
expect(b.pickupaddress).toBe(BUSINESS_LOCATION.address);
expect(b.ordertype).toBe(FLOW.FORWARD);
});
});
});
});

View File

@@ -0,0 +1,298 @@
import fs from 'node:fs';
import path from 'node:path';
import * as XLSX from 'xlsx';
import { SHEET_HEADER_MAP, cleanReceiverName, normalizeHeader, requiredSheetColumns } from '@/lib/bulkOrderColumns';
import { buildBulkBookingPayloads } from '@/lib/bulkOrderPayload';
import { PICKUP_SOURCE_TYPE, buildAnchors, classifyBooking } from '@/lib/orderFlow';
/**
* Bulk upload for the LOGISTICS path — the base-handover flow.
*
* `tests/fixtures/doormile-logistics-test.xlsx` exists because the tenant's own
* export (jayanthi-kitchen-route.xls, covered by bulkUpload.test.js) is 13 lunch
* drops entirely inside Coimbatore. Every row of it is hyperlocal, so uploading
* it exercises none of the intercity path: no parcel is ever routed to a base,
* and the rider is never told to hand one over.
*
* This fixture keeps that file's exact column titles — so it travels through
* the same header map and the same page code — and varies only the delivery
* end, which is what the routing decision is made from. 16 rows: 11 that must
* route through a base, 5 that must go straight to the customer.
*
* WHAT THIS SUITE DOES AND DOES NOT ASSERT.
*
* The routing decision is the BACKEND's, made in Go by isHyperlocalBooking.
* Re-implementing that rule here would only prove that two copies of it agree
* with each other today — the exact trap this repo's other suites call out. So
* the destinations are asserted against the real rule in
* doormile_backend/controllers/logisticsRouting_test.go, and what is asserted
* HERE is what the console is actually responsible for: that every row survives
* the sheet-to-wire transform with the fields the backend needs to make that
* decision, and that the pickup source type goes out correctly.
*/
const FIXTURE = path.join(__dirname, '..', 'fixtures', 'doormile-logistics-test.xlsx');
// The tenant's selected pickup location — Jayanthi's kitchen, which is also a
// registered site, at the same coordinates as the Coimbatore hub.
const BUSINESS_LOCATION = {
tenantlocationid: 31,
tenantid: 4,
hubid: 7,
locationname: 'Jayanthi Kitchen',
address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025',
city: 'Coimbatore',
pincode: '641025',
latitude: 11.0168,
longitude: 76.9558
};
const DOORMILE_BASE = {
hubid: 7,
hubname: 'Coimbatore Gandhipuram Hub',
address: '14 Avinashi Road, Peelamedu, Coimbatore',
pincode: '641004',
latitude: 11.0272,
longitude: 76.9905
};
const DELIVERY_TIME = '2026-09-02 09:00:00';
/** The page's own parse (MultipleOrders.processData), with the real map. */
const parseFixture = () => {
const wb = XLSX.read(fs.readFileSync(FIXTURE), { type: 'buffer' });
const sheet = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json(sheet, { defval: '' });
const normalizedMap = {};
for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key];
const rows = raw.map((row, idx) => {
const out = { _rowkey: `csv-${idx}`, quantity: 1, collectionamt: 0 };
for (const key in row) {
const clean = normalizeHeader(key);
const mapped = normalizedMap[clean] || clean;
out[mapped] = mapped === 'firstname' ? cleanReceiverName(row[key]) : row[key];
}
return out;
});
return { raw, rows, headers: Object.keys(raw[0]).map(normalizeHeader) };
};
const processRows = (rows) =>
rows.map((row) => ({ ...row, __pickup: null, distance: 10, totalcharge: 120 }));
const anchors = buildAnchors({ hubs: [DOORMILE_BASE], tenantLocations: [BUSINESS_LOCATION] });
const payloads = buildBulkBookingPayloads({
rows: processRows(parseFixture().rows),
sharedPickup: BUSINESS_LOCATION,
tenantId: 4,
tenantLocationId: 31,
deliverytime: DELIVERY_TIME,
anchors
});
/** Same rule the backend applies, stated once here ONLY to partition the
* fixture for counting — never to assert an individual row's routing, which is
* the backend test's job. */
const differentPostalArea = (p) =>
!(String(p.pickuppincode).length >= 3 &&
String(p.deliverypincode).length >= 3 &&
String(p.pickuppincode).slice(0, 3) === String(p.deliverypincode).slice(0, 3));
describe('logistics bulk upload — doormile-logistics-test.xlsx', () => {
describe('the fixture itself', () => {
it('should be a single sheet of 16 rows', () => {
const { raw } = parseFixture();
expect(raw).toHaveLength(16);
});
it('should use the tenant sheet\'s own column titles, so nothing new has to be learned', () => {
const { headers } = parseFixture();
['sendername*', 'senderphone*', 'senderaddress*', 'receivername*', 'receiveralternatephone*', 'itemdescription*']
.forEach((h) => expect(headers).toContain(h));
});
it('should report no missing required columns', () => {
const { headers } = parseFixture();
const missing = requiredSheetColumns().filter((c) => !headers.includes(normalizeHeader(c)));
expect(missing).toEqual([]);
});
it('should carry a pincode column, which the tenant file does not', () => {
// Without one, every row lands on the backend's distance fallback and the
// prefix rule — the thing that actually decides intercity — never runs.
const { headers } = parseFixture();
expect(headers).toContain('receiverpincode');
expect(SHEET_HEADER_MAP.receiverpincode).toBe('postcode');
});
it('should leave from ONE origin, so the delivery end is the only variable', () => {
const { rows } = parseFixture();
const senders = new Set(rows.map((r) => String(r.locationaddress).trim()));
expect(senders.size).toBe(1);
});
it('should actually split both ways, or it is testing nothing', () => {
// A fixture that turned out to be all-hyperlocal would pass every other
// assertion here and still exercise none of the handover path — which is
// precisely what is wrong with the tenant's own export.
const viaBase = payloads.filter(differentPostalArea);
expect(viaBase.length).toBeGreaterThanOrEqual(10);
expect(payloads.length - viaBase.length).toBeGreaterThanOrEqual(4);
});
});
describe('what the backend needs in order to route at all', () => {
it('should produce one booking per sheet row, losing none', () => {
expect(payloads).toHaveLength(16);
});
it('should give every row a pickup pincode, or nothing can be compared', () => {
payloads.forEach((p) => expect(String(p.pickuppincode)).toHaveLength(6));
});
it('should give every row real pickup coordinates, never 0,0', () => {
payloads.forEach((p) => {
expect(p.pickuplatitude).not.toBe(0);
expect(p.pickuplongitude).not.toBe(0);
});
});
it('should give every row delivery coordinates, so the distance fallback can run', () => {
// Three rows deliberately carry no usable pincode. For those, coordinates
// are the ONLY thing the backend can decide from.
payloads.forEach((p) => {
expect(Number(p.deliverylatitude)).not.toBe(0);
expect(Number(p.deliverylongitude)).not.toBe(0);
});
});
it('should carry the delivery pincode through as a string, not a number', () => {
// A pincode read as a number loses a leading zero and stops being 6 long,
// which silently changes which 3-digit area it compares as.
payloads.filter((p) => p.deliverypincode !== '').forEach((p) => {
expect(typeof p.deliverypincode).toBe('string');
});
});
it('should preserve the rows that intentionally have no usable pincode', () => {
// These are the fallback cases and must NOT be quietly filled in.
const unusable = payloads.filter((p) => String(p.deliverypincode).length < 3);
expect(unusable.length).toBe(3);
});
});
describe('pickup source — what kind of place these leave from', () => {
it('should name a source type on every single booking', () => {
// "customer" is a value, never an omission — an absent field reads as
// "nobody filled this in", which is a different fact from "front door".
payloads.forEach((p) => {
expect(p.pickup_source_type).toBeDefined();
expect(Object.values(PICKUP_SOURCE_TYPE)).toContain(p.pickup_source_type);
});
});
it('should call a collection at the client\'s registered site a merchant pickup', () => {
// Jayanthi's kitchen is a registered tenant location. Geometrically it is
// a facility, but to a rider it is a shop, not a base — and titling the
// stop "base" is what made every logistics pickup read as the rider's own
// office.
payloads.forEach((p) => expect(p.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT));
});
it('should not name a base on a pickup that is not from one', () => {
payloads.forEach((p) => expect(p.pickuphubid).toBeUndefined());
});
it('should stamp the servicing base so the orders land in a zone', () => {
payloads.forEach((p) => {
expect(p.sourcehubid).toBe(DOORMILE_BASE.hubid);
expect(p.hubid).toBe(DOORMILE_BASE.hubid);
});
});
it('should read back as a merchant pickup once the row returns from the server', () => {
// The write path and the read path must tell the same story.
const readBack = classifyBooking(
{ ...payloads[0], bookingid: 1, pickup_source_type: payloads[0].pickup_source_type },
anchors
);
expect(readBack.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.MERCHANT);
expect(readBack.sourceTypeFromServer).toBe(true);
});
});
describe('a base-origin upload — the Base → Customer case', () => {
// Same sheet, but the operator has selected a BASE as the pickup point
// rather than a client site. This is the AC-2 journey: Home must show the
// base as the actual pickup source.
const fromBase = buildBulkBookingPayloads({
rows: processRows(parseFixture().rows),
sharedPickup: {
...DOORMILE_BASE,
latitude: DOORMILE_BASE.latitude,
longitude: DOORMILE_BASE.longitude,
pincode: DOORMILE_BASE.pincode,
address: DOORMILE_BASE.address
},
tenantId: 4,
tenantLocationId: '',
deliverytime: DELIVERY_TIME,
anchors
});
it('should classify every row as a base pickup', () => {
fromBase.forEach((p) => expect(p.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB));
});
it('should carry the base id, which is what keys the rider handover', () => {
fromBase.forEach((p) => expect(p.pickuphubid).toBe(DOORMILE_BASE.hubid));
});
it('should put the base address on the booking, not a customer door', () => {
fromBase.forEach((p) => expect(p.pickupaddress).toBe(DOORMILE_BASE.address));
});
});
describe('the rest of the row survives the transform', () => {
it('should carry every receiver name through in sheet order', () => {
const { rows } = parseFixture();
payloads.forEach((p, i) => expect(p.customer_name).toBe(rows[i].firstname));
});
it('should send phone numbers as strings even though the sheet types them as numbers', () => {
payloads.forEach((p) => expect(typeof p.customer_phone).toBe('string'));
});
it('should expand quantity into one parcel entry per item', () => {
const { rows } = parseFixture();
payloads.forEach((p, i) => {
expect(p.parcels.length).toBe(Math.max(1, Number(rows[i].quantity) || 1));
});
});
it('should keep the COD rows, which must survive a base leg', () => {
// Three rows carry cash to collect. A parcel handed over at a base and
// carried onward still has money attached to it at the far end.
const { rows } = parseFixture();
const cod = rows.filter((r) => Number(r.collectionamt) > 0);
expect(cod.length).toBe(3);
});
it('should put the same delivery time on every booking in one run', () => {
payloads.forEach((p) => expect(p.deliverytime).toBe(DELIVERY_TIME));
});
it('should ignore the human-readable notes column rather than send it', () => {
// The sheet carries an "Expected Routing" column for whoever is running
// the test by hand. It must not reach the wire.
payloads.forEach((p) => {
expect(Object.keys(p).some((k) => k.toLowerCase().includes('expected'))).toBe(false);
});
});
});
});

View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
jest.mock('lucide-react', () => ({
Check: () => <span data-testid="icon-check" />,
Minus: () => <span data-testid="icon-minus" />,
ArrowDown: () => <span data-testid="icon-down" />,
ArrowUp: () => <span data-testid="icon-up" />,
ChevronsUpDown: () => <span data-testid="icon-sort" />,
Inbox: () => <span data-testid="icon-inbox" />,
ChevronLeft: () => <span data-testid="icon-left" />,
ChevronRight: () => <span data-testid="icon-right" />,
ChevronsLeft: () => <span data-testid="icon-first" />,
ChevronsRight: () => <span data-testid="icon-last" />,
}));
import { DataTable } from '@/components/ds/DataTable';
const COLUMNS = [
{ key: 'id', header: 'ID' },
{ key: 'title', header: 'Title' },
];
const createMockRows = (count = 60) =>
Array.from({ length: count }, (_, i) => ({
id: `order-${i + 1}`,
title: `Order #${i + 1}`,
}));
function TestTableWrapper({
rowCount = 60,
pageSize = 25,
selectAllPages = true,
initialSelected = [],
}) {
const [selectedIds, setSelectedIds] = useState(initialSelected);
const rows = createMockRows(rowCount);
return (
<div>
<div data-testid="selected-count">{selectedIds.length}</div>
<DataTable
columns={COLUMNS}
rows={rows}
getRowId={(row) => row.id}
pageSize={pageSize}
selectAllPages={selectAllPages}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
</div>
);
}
describe('DataTable selection across pagination', () => {
it('should select ALL 60 rows across all pages when selectAllPages is true', () => {
render(<TestTableWrapper rowCount={60} pageSize={25} selectAllPages={true} />);
expect(screen.getByTestId('selected-count').textContent).toBe('0');
// Header checkbox for select all
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i });
expect(selectAllCheckbox).not.toBeChecked();
// Click select all
fireEvent.click(selectAllCheckbox);
// All 60 rows should now be selected
expect(screen.getByTestId('selected-count').textContent).toBe('60');
expect(selectAllCheckbox).toBeChecked();
// Click again to unselect all
fireEvent.click(selectAllCheckbox);
expect(screen.getByTestId('selected-count').textContent).toBe('0');
expect(selectAllCheckbox).not.toBeChecked();
});
it('should select ONLY current page (25 rows) when selectAllPages is false', () => {
render(<TestTableWrapper rowCount={60} pageSize={25} selectAllPages={false} />);
expect(screen.getByTestId('selected-count').textContent).toBe('0');
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows on this page/i });
fireEvent.click(selectAllCheckbox);
// Only the 25 rows on page 1 should be selected
expect(screen.getByTestId('selected-count').textContent).toBe('25');
});
it('should reflect indeterminate state when some rows are selected', () => {
render(
<TestTableWrapper
rowCount={60}
pageSize={25}
selectAllPages={true}
initialSelected={['order-1', 'order-2', 'order-3']}
/>
);
expect(screen.getByTestId('selected-count').textContent).toBe('3');
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i });
expect(selectAllCheckbox.getAttribute('data-state') === 'indeterminate' || selectAllCheckbox.indeterminate).toBeTruthy();
});
it('should toggle individual row selection without affecting other pages', () => {
render(<TestTableWrapper rowCount={60} pageSize={25} selectAllPages={true} />);
const rowCheckboxes = screen.getAllByRole('checkbox');
// First checkbox is header, second is row 1
const firstRowCheckbox = rowCheckboxes[1];
fireEvent.click(firstRowCheckbox);
expect(screen.getByTestId('selected-count').textContent).toBe('1');
fireEvent.click(firstRowCheckbox);
expect(screen.getByTestId('selected-count').textContent).toBe('0');
});
});

View File

@@ -0,0 +1,81 @@
import { dropCountOf, dropSummary, isMultiDrop, plural } from '@/lib/bookingDrops';
describe('dropCountOf', () => {
// The console can ship before or after the backend that adds these fields.
// An absent count must read as 0 so every multi-drop behaviour stays off and
// the page renders exactly as it did.
it('treats an absent destinationcount as zero', () => {
expect(dropCountOf({})).toBe(0);
expect(dropCountOf({ destinationcount: undefined })).toBe(0);
expect(dropCountOf(undefined)).toBe(0);
expect(dropCountOf(null)).toBe(0);
});
it('reads the count the backend sent', () => {
expect(dropCountOf({ destinationcount: 3 })).toBe(3);
expect(dropCountOf({ destinationcount: 1 })).toBe(1);
});
// A console-created booking has no bookingdestinations rows at all.
it('keeps a console-created booking at zero', () => {
expect(dropCountOf({ destinationcount: 0 })).toBe(0);
});
});
describe('isMultiDrop', () => {
// THE IMPORTANT ONE. This gate is what stops the Orders row rendering the
// booking's own consignmentstatus — which is the FIRST leg's status wearing
// the whole booking's name — as the status of a three-drop pickup.
it('is true only above one destination', () => {
expect(isMultiDrop({ destinationcount: 2 })).toBe(true);
expect(isMultiDrop({ destinationcount: 3 })).toBe(true);
});
it('is false for a single drop, so the row keeps todays behaviour', () => {
expect(isMultiDrop({ destinationcount: 1 })).toBe(false);
});
it('is false when the backend has not shipped the field yet', () => {
expect(isMultiDrop({})).toBe(false);
expect(isMultiDrop({ bookingid: 7, status: 'converted_to_consignment' })).toBe(false);
});
it('is false for a console-created booking reporting zero', () => {
expect(isMultiDrop({ destinationcount: 0 })).toBe(false);
});
});
describe('dropSummary', () => {
it('reads as the handoff specifies', () => {
expect(dropSummary({ destinationcount: 3, totalpackagecount: 4 })).toBe(
'3 destinations · 4 packages'
);
});
// Every destination carries at least one package, so a zero package total is
// a stale or partial response rather than a real booking. Falling back to the
// destination count keeps the line truthful instead of printing "0 packages".
it('falls back to the destination count when the package sum is missing', () => {
expect(dropSummary({ destinationcount: 3 })).toBe('3 destinations · 3 packages');
expect(dropSummary({ destinationcount: 2, totalpackagecount: 0 })).toBe(
'2 destinations · 2 packages'
);
});
it('pluralises both nouns independently', () => {
expect(dropSummary({ destinationcount: 2, totalpackagecount: 1 })).toBe(
'2 destinations · 1 package'
);
expect(dropSummary({ destinationcount: 1, totalpackagecount: 2 })).toBe(
'1 destination · 2 packages'
);
});
});
describe('plural', () => {
it('keeps the singular at exactly one', () => {
expect(plural(1, 'package')).toBe('1 package');
expect(plural(0, 'package')).toBe('0 packages');
expect(plural(2, 'destination')).toBe('2 destinations');
});
});

View File

@@ -0,0 +1,173 @@
import {
BOOKING_SOURCE,
BOOKING_TABS,
bookingsForTab,
customerAppBookings,
isCustomerAppBooking,
quotedPrice,
serviceOptionOf,
serviceType,
} from '@/lib/customerAppBookings';
/**
* Which bookings belong on the Bookings page.
*
* The page shows what customers booked in the app; Orders shows what this
* console created. `bookingsource` is the only field that separates them, and
* the trap is its second value: the API and routes were renamed from "CRM" to
* "express", but the STORED column value was deliberately left as
* `"CRM_Console"` because it is existing data rather than a label. Matching on
* "express" would silently show every console order on the customer page.
*/
const appBooking = (over = {}) => ({
bookingid: 1,
bookingno: 'BK-1',
bookingsource: 'Customer_App',
status: 'Pending_Pickup',
...over,
});
const consoleBooking = (over = {}) => ({
bookingid: 2,
bookingno: 'BK-2',
bookingsource: 'CRM_Console',
status: 'Pending_Pickup',
...over,
});
describe('isCustomerAppBooking', () => {
it('should accept a booking made in the app', () => {
expect(isCustomerAppBooking(appBooking())).toBe(true);
});
it('should reject a console-created booking', () => {
expect(isCustomerAppBooking(consoleBooking())).toBe(false);
});
it('should still reject the console value under its post-rename name', () => {
// The outward naming became "express"; the stored value never did. Anything
// that starts matching on "express" here has misunderstood that.
expect(BOOKING_SOURCE.CONSOLE).toBe('CRM_Console');
expect(isCustomerAppBooking({ bookingsource: 'express' })).toBe(false);
});
it('should tolerate casing and stray whitespace', () => {
// Compared against a raw database column, not a validated enum.
expect(isCustomerAppBooking({ bookingsource: ' customer_app ' })).toBe(true);
expect(isCustomerAppBooking({ bookingsource: 'CUSTOMER_APP' })).toBe(true);
});
it('should not claim a booking with no source', () => {
// Filing unknown rows under "customer app" would overstate B2C volume, and
// that is a number people make decisions on.
expect(isCustomerAppBooking({})).toBe(false);
expect(isCustomerAppBooking({ bookingsource: '' })).toBe(false);
expect(isCustomerAppBooking(null)).toBe(false);
expect(isCustomerAppBooking(undefined)).toBe(false);
});
});
describe('customerAppBookings', () => {
it('should keep only app bookings and drop console ones', () => {
const list = [appBooking({ bookingid: 1 }), consoleBooking({ bookingid: 2 }), appBooking({ bookingid: 3 })];
expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([3, 1]);
});
it('should order newest first', () => {
const list = [appBooking({ bookingid: 5 }), appBooking({ bookingid: 42 }), appBooking({ bookingid: 12 })];
expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([42, 12, 5]);
});
it('should not mutate the list it was given', () => {
// The same array is the Orders page's cache entry — sorting it in place
// would reorder another page's rows underneath it.
const list = [appBooking({ bookingid: 1 }), appBooking({ bookingid: 9 })];
customerAppBookings(list);
expect(list.map((b) => b.bookingid)).toEqual([1, 9]);
});
it('should return an empty list rather than throw on nothing', () => {
expect(customerAppBookings(undefined)).toEqual([]);
expect(customerAppBookings(null)).toEqual([]);
expect(customerAppBookings([])).toEqual([]);
});
});
describe('the price quote', () => {
const withOptions = (options) => appBooking({ serviceoptions: options });
it('should read the service option the booking carries', () => {
const b = withOptions([{ servicetype: 'Fast', estimatedprice: 210, createdat: '2026-09-01T10:00:00Z' }]);
expect(serviceType(b)).toBe('Fast');
expect(quotedPrice(b)).toBe(210);
});
it('should take the newest option when a booking has several', () => {
// Matches the backend's own `ORDER BY createdat DESC LIMIT 1`, so the page
// and GET /customer/bookings/{id}/price cannot disagree.
const b = withOptions([
{ servicetype: 'Normal', estimatedprice: 100, createdat: '2026-09-01T10:00:00Z' },
{ servicetype: 'Superfast', estimatedprice: 300, createdat: '2026-09-02T10:00:00Z' },
]);
expect(serviceOptionOf(b).servicetype).toBe('Superfast');
expect(quotedPrice(b)).toBe(300);
});
it('should report no quote rather than a zero price', () => {
// A booking with no service option has no price. Rendering 0 would read as
// "free", which is a different claim.
expect(serviceOptionOf(appBooking())).toBeNull();
expect(serviceOptionOf(withOptions([]))).toBeNull();
expect(serviceType(appBooking())).toBe('');
});
it('should not throw on a missing booking', () => {
expect(() => quotedPrice(undefined)).not.toThrow();
expect(quotedPrice(undefined)).toBe(0);
});
});
describe('the status tabs', () => {
it('should let the All tab through untouched', () => {
const list = [appBooking({ status: 'Pending_Pickup' }), appBooking({ status: 'Cancelled' })];
expect(bookingsForTab(list, 'all')).toHaveLength(2);
});
it('should put an uncollected unassigned booking under Created tab', () => {
const list = [
appBooking({ bookingid: 1, status: 'Pending_Pickup' }),
appBooking({ bookingid: 2, status: 'Created' }),
appBooking({ bookingid: 3, status: 'Miler_Assigned' }),
];
expect(bookingsForTab(list, 'created').map((b) => b.bookingid)).toEqual([1, 2]);
});
it('should put an assigned / in-progress booking under Rider Assigned tab', () => {
const list = [
appBooking({ bookingid: 1, status: 'Miler_Assigned' }),
appBooking({ bookingid: 2, status: 'Converted_To_Consignment' }),
appBooking({ bookingid: 3, status: 'Pending_Pickup' }),
];
expect(bookingsForTab(list, 'assigned').map((b) => b.bookingid)).toEqual([1, 2]);
});
it('should match status case-insensitively', () => {
expect(bookingsForTab([appBooking({ status: 'CANCELLED' })], 'cancelled')).toHaveLength(1);
});
it('should fall back to showing everything for an unknown tab', () => {
// An unknown tab value can only come from a bug in the page. Falling back to
// the full list keeps the data visible; silently returning nothing would
// look identical to "this customer has no bookings", which is a lie the
// operator cannot see through.
expect(bookingsForTab([appBooking()], 'nonsense')).toHaveLength(1);
});
it('should give every tab a value and a label', () => {
BOOKING_TABS.forEach((t) => {
expect(t.value).toBeTruthy();
expect(t.label).toBeTruthy();
});
});
});

134
tests/lib/hubForm.test.js Normal file
View File

@@ -0,0 +1,134 @@
import { validateHubForm } from '@/lib/hubForm';
/**
* A hub in this console is a BASE to a rider.
*
* The bug these tests exist to prevent is already in production data: of the 18
* hubs live today, one carries no address and no coordinates. Only `hubname`
* and `applocationid` were ever validated, so a base could be saved that the
* rider app can name but cannot route anyone to — Navigate does nothing without
* a pin, and a rider standing outside an industrial estate has nothing to read
* without an address.
*
* The backend hands six fields to the rider app for a base. Five of six is not
* "mostly there"; it is a rider with a parcel and no way to deliver it.
*/
const VALID = {
hubname: 'Coimbatore Gandhipuram Hub',
applocationid: 1,
address: '14 Avinashi Road, Peelamedu, Coimbatore',
pincode: '641004',
latitude: '11.0168',
longitude: '76.9558'
};
describe('validateHubForm', () => {
describe('a complete base', () => {
it('should accept a form carrying all six rider-facing fields', () => {
const { errors, isValid } = validateHubForm(VALID);
expect(errors).toEqual({});
expect(isValid).toBe(true);
});
it('should hand back coordinates already coerced, so the caller does not re-parse', () => {
const { values } = validateHubForm(VALID);
expect(values.latitude).toBe(11.0168);
expect(values.longitude).toBe(76.9558);
expect(values.applocationid).toBe(1);
});
it('should accept a southern or western hemisphere base', () => {
// Nothing in the range check may assume India.
expect(validateHubForm({ ...VALID, latitude: '-33.8688', longitude: '-70.6693' }).isValid).toBe(true);
});
});
describe('the fields that were never checked', () => {
it('should reject a base with no address, because the rider reads it at the gate', () => {
const { errors, isValid } = validateHubForm({ ...VALID, address: '' });
expect(isValid).toBe(false);
expect(errors.address).toBeTruthy();
});
it('should reject whitespace as an address', () => {
expect(validateHubForm({ ...VALID, address: ' ' }).errors.address).toBeTruthy();
});
it('should reject a base with no pincode', () => {
expect(validateHubForm({ ...VALID, pincode: '' }).errors.pincode).toBeTruthy();
});
it('should reject a base with no coordinates, because Navigate cannot work', () => {
const { errors, isValid } = validateHubForm({ ...VALID, latitude: '', longitude: '' });
expect(isValid).toBe(false);
expect(errors.latitude).toBeTruthy();
expect(errors.longitude).toBeTruthy();
});
it('should treat 0,0 as unset rather than as a location', () => {
// The previous code coerced an empty coordinate to 0 and sent it. 0,0 is
// in the Gulf of Guinea; it is always an unset value, never a base.
const { errors } = validateHubForm({ ...VALID, latitude: '0', longitude: '0' });
expect(errors.latitude).toBeTruthy();
expect(errors.longitude).toBeTruthy();
});
it('should reject a single missing coordinate, not just both', () => {
// Half a pin is no pin.
expect(validateHubForm({ ...VALID, latitude: '' }).isValid).toBe(false);
expect(validateHubForm({ ...VALID, longitude: '' }).isValid).toBe(false);
});
it('should reject coordinates outside the possible range', () => {
expect(validateHubForm({ ...VALID, latitude: '91' }).errors.latitude).toBeTruthy();
expect(validateHubForm({ ...VALID, latitude: '-91' }).errors.latitude).toBeTruthy();
expect(validateHubForm({ ...VALID, longitude: '181' }).errors.longitude).toBeTruthy();
expect(validateHubForm({ ...VALID, longitude: '-181' }).errors.longitude).toBeTruthy();
});
it('should reject a coordinate that is not a number at all', () => {
expect(validateHubForm({ ...VALID, latitude: 'near the flyover' }).errors.latitude).toBeTruthy();
});
it('should catch a swapped lat/lng pair for an Indian base', () => {
// 76.9 as a latitude is inside the Arctic Circle. The range check is the
// only thing standing between a transposed paste and a base in the sea.
expect(validateHubForm({ ...VALID, latitude: '76.9558', longitude: '11.0168' }).isValid).toBe(true);
// ...but a longitude beyond 90 in the latitude slot is caught outright.
expect(validateHubForm({ ...VALID, latitude: '176.9558' }).errors.latitude).toBeTruthy();
});
});
describe('the checks that already existed', () => {
it('should still require a name', () => {
expect(validateHubForm({ ...VALID, hubname: '' }).errors.hubname).toBeTruthy();
expect(validateHubForm({ ...VALID, hubname: ' ' }).errors.hubname).toBeTruthy();
});
it('should still require a numeric city id', () => {
expect(validateHubForm({ ...VALID, applocationid: '' }).errors.applocationid).toBeTruthy();
expect(validateHubForm({ ...VALID, applocationid: 'Coimbatore' }).errors.applocationid).toBeTruthy();
});
it('should accept city id 0, which is a valid id and not an absence', () => {
expect(validateHubForm({ ...VALID, applocationid: 0 }).errors.applocationid).toBeUndefined();
});
});
describe('robustness', () => {
it('should report every problem at once rather than one at a time', () => {
// An operator fixing a form field by field, submit by submit, is a worse
// experience than being told everything that is wrong.
const { errors } = validateHubForm({});
expect(Object.keys(errors).sort()).toEqual(
['address', 'applocationid', 'hubname', 'latitude', 'longitude', 'pincode'].sort()
);
});
it('should not throw on an empty or missing form', () => {
expect(() => validateHubForm()).not.toThrow();
expect(validateHubForm().isValid).toBe(false);
});
});
});

480
tests/lib/orderFlow.test.js Normal file
View File

@@ -0,0 +1,480 @@
import {
ANCHOR_RADIUS_M,
DROP_TARGET,
FLOW,
PICKUP_SOURCE,
PICKUP_SOURCE_TYPE,
buildAnchors,
buildFlowFields,
classifyBooking,
flowForDraft,
flowLabel,
hasCustomerPickup,
hasHubDrop,
matchAnchor,
normalizeFlow
} from '@/lib/orderFlow';
/**
* Which end of a booking is a Doormile facility.
*
* The bug these tests exist to prevent: the console assumed the pickup was
* ALWAYS the hub. queries.js used the tenant's name as the collection point's
* identity, so every customer pickup for one client collapsed onto a single map
* pin placed at an arbitrary one of them, and the rider's polyline skipped the
* entire first-mile leg.
*
* Two invariants carry the most weight here and are asserted repeatedly:
*
* 1. UNKNOWABLE MEANS FORWARD. No anchors loaded, no coordinates, a 0,0
* "unset" — none of these may produce a customer pickup. An order that
* cannot be classified has to look exactly as it did before this module
* existed, or a data problem starts drawing phantom collection points.
* 2. A CUSTOMER PICKUP NEVER SHARES A GROUPING KEY. Two doorsteps are two
* places. Merging them is precisely the defect being replaced.
*/
// Real Coimbatore-ish coordinates, so the distances under test are the
// magnitudes the radius was actually chosen for.
const HUB = { hubid: 7, hubname: 'Gandhipuram Hub', latitude: 11.0168, longitude: 76.9558 };
const KITCHEN = {
tenantlocationid: 31,
tenantid: 4,
locationname: '124 Gandhipuram Main Rd',
latitude: 11.021,
longitude: 76.962
};
// ~4 km and ~6 km out — unambiguously somebody's door.
const DOOR_A = { lat: 11.05, lng: 77.0 };
const DOOR_B = { lat: 11.06, lng: 77.01 };
const anchors = buildAnchors({ hubs: [HUB], tenantLocations: [KITCHEN] });
const booking = ({ id = 1, from, to, ...rest }) => ({
bookingid: id,
pickuplatitude: from.lat,
pickuplongitude: from.lng,
deliverylatitude: to.lat,
deliverylongitude: to.lng,
...rest
});
const AT_HUB = { lat: HUB.latitude, lng: HUB.longitude };
const AT_KITCHEN = { lat: KITCHEN.latitude, lng: KITCHEN.longitude };
describe('orderFlow', () => {
describe('buildAnchors', () => {
it('should treat both a Doormile hub and a client location as facilities', () => {
expect(anchors).toHaveLength(2);
expect(anchors.map((a) => a.kind).sort()).toEqual(['hub', 'tenantlocation']);
});
it('should give each anchor a key that is unique per facility', () => {
expect(new Set(anchors.map((a) => a.key)).size).toBe(anchors.length);
});
it('should drop a facility nobody geocoded rather than keep it with NaN coordinates', () => {
const built = buildAnchors({ hubs: [{ hubid: 9, hubname: 'Unmapped' }, HUB] });
expect(built.map((a) => a.hubid)).toEqual([7]);
});
it('should return an empty set when called with nothing', () => {
expect(buildAnchors()).toEqual([]);
});
it('should give a tenant location the hub that services its city', () => {
// A tenant location record has NO hubid field — the API does not carry
// one. Without this, every order originating at a client's own kitchen
// went to the backend with no servicing hub, and zone attribution fell
// straight back to matching hub names against address text.
const kitchen = anchors.find((a) => a.kind === 'tenantlocation');
expect(kitchen.hubid).toBe(7);
});
it('should not invent a servicing hub for a location in another city', () => {
// Chennai, ~400 km away. A wrong hub id is worse than none: it files the
// order into a zone it has nothing to do with, where nobody is looking.
const [faraway] = buildAnchors({
hubs: [HUB],
tenantLocations: [{ tenantlocationid: 99, locationname: 'Chennai Depot', latitude: 13.0827, longitude: 80.2707 }]
}).filter((a) => a.kind === 'tenantlocation');
expect(faraway.hubid).toBeNull();
});
it("should keep a location's own hubid when the record already has one", () => {
const [explicit] = buildAnchors({
hubs: [HUB],
tenantLocations: [{ ...KITCHEN, hubid: 12 }]
}).filter((a) => a.kind === 'tenantlocation');
expect(explicit.hubid).toBe(12);
});
it('should leave hubid null when no hubs were loaded at all', () => {
const [alone] = buildAnchors({ tenantLocations: [KITCHEN] });
expect(alone.hubid).toBeNull();
});
});
describe('matchAnchor', () => {
it('should match a point sitting on a facility', () => {
expect(matchAnchor(HUB.latitude, HUB.longitude, anchors)?.hubid).toBe(7);
});
it('should not match a customer door several km away', () => {
expect(matchAnchor(DOOR_A.lat, DOOR_A.lng, anchors)).toBeNull();
});
it('should choose the NEAREST facility, not the first one in the array', () => {
// Two facilities sharing a compound is normal. Picking by array order
// would name the same pin differently depending on what /admin/hubs
// happened to return first.
const twinA = { hubid: 1, hubname: 'Sorting Centre', latitude: 11.0168, longitude: 76.9558 };
const twinB = { hubid: 2, hubname: 'Client Depot', latitude: 11.01685, longitude: 76.95585 };
const both = buildAnchors({ hubs: [twinA, twinB] });
expect(matchAnchor(twinB.latitude, twinB.longitude, both)?.hubid).toBe(2);
expect(matchAnchor(twinA.latitude, twinA.longitude, both)?.hubid).toBe(1);
});
it('should never match a 0,0 "unset" coordinate against a real facility', () => {
expect(matchAnchor(0, 0, anchors)).toBeNull();
});
it('should treat the radius as a boundary in metres, not degrees', () => {
// ~100 m north of the hub: inside. ~500 m: outside.
const inside = HUB.latitude + 0.0009;
const outside = HUB.latitude + 0.0045;
expect(matchAnchor(inside, HUB.longitude, anchors)).not.toBeNull();
expect(matchAnchor(outside, HUB.longitude, anchors)).toBeNull();
expect(ANCHOR_RADIUS_M).toBe(200);
});
});
describe('classifyBooking — the four flows', () => {
it('should classify hub to customer as Forward', () => {
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
expect(r.flow).toBe(FLOW.FORWARD);
expect(r.pickupSource).toBe(PICKUP_SOURCE.HUB);
expect(r.dropTarget).toBe(DROP_TARGET.CUSTOMER);
});
it('should classify customer to hub as Reverse — the customer pickup', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.pickupSource).toBe(PICKUP_SOURCE.CUSTOMER);
expect(r.dropTarget).toBe(DROP_TARGET.HUB);
});
it('should classify customer to customer as P2P', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors);
expect(r.flow).toBe(FLOW.P2P);
});
it('should classify hub to hub as Linehaul', () => {
const r = classifyBooking(booking({ from: AT_KITCHEN, to: AT_HUB }), anchors);
expect(r.flow).toBe(FLOW.LINEHAUL);
});
});
describe('classifyBooking — unknowable input stays Forward', () => {
it('should fall back to Forward when no facility list has loaded', () => {
// Geometry proves nothing without anchors. Anything but Forward here
// would relabel the whole board during the first render, before
// /admin/hubs has answered.
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), []).flow).toBe(FLOW.FORWARD);
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B })).flow).toBe(FLOW.FORWARD);
});
it('should fall back to Forward for a pickup that failed to geocode', () => {
// 0,0 is the backend's "unset". A pickup that never geocoded is a data
// problem, not a doorstep collection — calling it one would put a
// phantom violet pin on the map off the coast of Africa.
const unset = classifyBooking(booking({ id: 9, from: { lat: 0, lng: 0 }, to: DOOR_A }), anchors);
expect(unset.flow).toBe(FLOW.FORWARD);
const missing = classifyBooking({ bookingid: 10, deliverylatitude: DOOR_A.lat, deliverylongitude: DOOR_A.lng }, anchors);
expect(missing.flow).toBe(FLOW.FORWARD);
});
it('should not crash on a null or empty booking', () => {
expect(classifyBooking(null, anchors).flow).toBe(FLOW.FORWARD);
expect(classifyBooking({}, anchors).flow).toBe(FLOW.FORWARD);
});
});
describe('classifyBooking — an explicit ordertype wins over geometry', () => {
it('should trust a stored ordertype even when the coordinates disagree', () => {
// Coordinates say Forward; the record says otherwise. Once the backend
// persists the field it is the authority, because it records what the
// operator actually booked.
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A, ordertype: 'Reverse' }), anchors);
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.derived).toBe(false);
});
it('should mark a geometry-decided classification as derived', () => {
expect(classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors).derived).toBe(true);
});
it('should fall through to geometry when the stored value means nothing', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB, ordertype: 'banana' }), anchors);
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.derived).toBe(true);
});
});
describe('classifyBooking — the pickup point grouping key', () => {
it('should give every order off one facility the SAME key', () => {
const a = classifyBooking(booking({ id: 1, from: AT_KITCHEN, to: DOOR_A }), anchors);
const b = classifyBooking(booking({ id: 2, from: AT_KITCHEN, to: DOOR_B }), anchors);
expect(a.pickupPointKey).toBe(b.pickupPointKey);
});
it('should give every customer pickup its OWN key', () => {
// The defect being replaced: these two collapsed onto one map pin
// because both resolved to the tenant's name.
const a = classifyBooking(booking({ id: 1, from: DOOR_A, to: AT_HUB }), anchors);
const b = classifyBooking(booking({ id: 2, from: DOOR_B, to: AT_HUB }), anchors);
expect(a.pickupPointKey).not.toBe(b.pickupPointKey);
});
it('should name a facility pickup after the facility and a customer pickup after its address', () => {
const hub = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
expect(hub.pickupPointName).toBe('124 Gandhipuram Main Rd');
const door = classifyBooking(
booking({ from: DOOR_A, to: AT_HUB, pickupaddress: '22 Ponvizha Nagar' }),
anchors
);
expect(door.pickupPointName).toBe('22 Ponvizha Nagar');
});
});
describe('classifyBooking — servicing hub', () => {
it('should prefer an id the booking already carries', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B, sourcehubid: 42 }), anchors);
expect(r.servicingHubId).toBe(42);
});
it('should fall back to the DESTINATION hub on a customer pickup', () => {
// Nothing at the pickup end names a facility, but an inbound collection
// plainly belongs to the hub it is being brought to. Without this, every
// customer pickup would lose its zone.
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
expect(r.servicingHubId).toBe(7);
});
it('should resolve through a tenant location, not only through a Doormile hub', () => {
// The case that was silently broken: the pickup matches the client's own
// kitchen, which is not itself a hub. Before the kitchen inherited a
// servicing hub this returned null and the order reached the backend
// with no zone.
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
expect(r.originAnchor.kind).toBe('tenantlocation');
expect(r.servicingHubId).toBe(7);
});
it('should be null on a P2P run where neither end is a facility', () => {
// Deliberate: a fabricated hub id here would file the order under a zone
// it has nothing to do with. Null lets ZoneContext fall back to radius.
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors).servicingHubId).toBeNull();
});
});
describe('normalizeFlow', () => {
it.each([
['reverse', FLOW.REVERSE],
['REVERSE', FLOW.REVERSE],
['customer_pickup', FLOW.REVERSE],
['customer pickup', FLOW.REVERSE],
['customer-pickup', FLOW.REVERSE],
['hub_pickup', FLOW.FORWARD],
['outbound', FLOW.FORWARD],
['p2p', FLOW.P2P],
['point_to_point', FLOW.P2P],
['hyperlocal', FLOW.P2P],
['line_haul', FLOW.LINEHAUL]
])('should read %s as %s', (input, expected) => {
expect(normalizeFlow(input)).toBe(expected);
});
it('should return null for anything it does not recognise, never throw', () => {
[undefined, null, '', ' ', 'banana', 42, {}].forEach((value) => {
expect(normalizeFlow(value)).toBeNull();
});
});
});
describe('flow predicates', () => {
it('should identify which flows require travelling out to collect', () => {
expect(hasCustomerPickup(FLOW.REVERSE)).toBe(true);
expect(hasCustomerPickup(FLOW.P2P)).toBe(true);
expect(hasCustomerPickup(FLOW.FORWARD)).toBe(false);
expect(hasCustomerPickup(FLOW.LINEHAUL)).toBe(false);
});
it('should identify which flows end at a facility', () => {
expect(hasHubDrop(FLOW.REVERSE)).toBe(true);
expect(hasHubDrop(FLOW.LINEHAUL)).toBe(true);
expect(hasHubDrop(FLOW.FORWARD)).toBe(false);
expect(hasHubDrop(FLOW.P2P)).toBe(false);
});
it('should label every flow, and fall back rather than render blank', () => {
Object.values(FLOW).forEach((f) => expect(flowLabel(f)).toBeTruthy());
expect(flowLabel(FLOW.REVERSE)).toBe('Customer Pickup');
expect(flowLabel('nonsense')).toBe(flowLabel(FLOW.FORWARD));
});
});
describe('flowForDraft — what the create form books', () => {
it('should book a hub pickup to a typed address as Forward', () => {
const r = flowForDraft({
pickupSource: PICKUP_SOURCE.HUB,
dropLat: DOOR_A.lat,
dropLng: DOOR_A.lng,
anchors
});
expect(r.flow).toBe(FLOW.FORWARD);
});
it('should recognise a drop that lands on a hub as a return, not a delivery', () => {
// Without measuring the drop end, a rider bringing a parcel back to the
// hub would be booked P2P and routed as if the hub were a house.
const r = flowForDraft({
pickupSource: PICKUP_SOURCE.CUSTOMER,
dropLat: HUB.latitude,
dropLng: HUB.longitude,
anchors
});
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.destAnchor?.hubid).toBe(7);
});
it('should book a customer pickup to a customer address as P2P', () => {
const r = flowForDraft({
pickupSource: PICKUP_SOURCE.CUSTOMER,
dropLat: DOOR_B.lat,
dropLng: DOOR_B.lng,
anchors
});
expect(r.flow).toBe(FLOW.P2P);
});
it('should default to a hub pickup when the form has told it nothing', () => {
expect(flowForDraft().flow).toBe(FLOW.FORWARD);
});
});
describe('buildFlowFields — what goes on the wire', () => {
it('should always name the flow', () => {
expect(buildFlowFields({ flow: FLOW.REVERSE }).ordertype).toBe(FLOW.REVERSE);
});
it('should write BOTH hub id fields, which the console has only ever read', () => {
// queries.js has always read b.hubid / b.sourcehubid and the console has
// never written either, which is why zone attribution had to fall back to
// matching hub names against concatenated address text.
const fields = buildFlowFields({ flow: FLOW.FORWARD, servicingHubId: 7, tenantLocationId: 31 });
expect(fields.sourcehubid).toBe(7);
expect(fields.hubid).toBe(7);
expect(fields.tenantlocationid).toBe(31);
});
it('should omit ids it was not given rather than send null', () => {
// pickup_source_type is NOT an id and is always present — see the next
// block for why an omission would say the wrong thing.
expect(buildFlowFields({ flow: FLOW.P2P })).toEqual({
ordertype: FLOW.P2P,
pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
});
expect(buildFlowFields({ flow: FLOW.P2P, servicingHubId: '' })).toEqual({
ordertype: FLOW.P2P,
pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
});
});
it('should fall back to Forward rather than put an invented type on the wire', () => {
expect(buildFlowFields({ flow: 'banana' }).ordertype).toBe(FLOW.FORWARD);
expect(buildFlowFields().ordertype).toBe(FLOW.FORWARD);
});
});
describe('pickup_source_type — what kind of place the parcel leaves', () => {
const baseAnchor = anchors.find((a) => a.kind === 'hub');
const siteAnchor = anchors.find((a) => a.kind === 'tenantlocation');
it('should name a base pickup and carry the base id', () => {
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: baseAnchor });
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
expect(fields.pickuphubid).toBe(HUB.hubid);
});
it("should call a client's own site a merchant, not a base", () => {
// Both are facilities to the geometry, but a rider arriving at a kitchen
// is not arriving at a base — different signage, different person to ask
// for. Collapsing them is what made every logistics pickup read as the
// rider's own base.
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: siteAnchor });
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT);
expect(fields.pickuphubid).toBeUndefined();
});
it('should send "customer" as a value rather than leaving it off', () => {
// An omission reads as "nobody filled this in", which is a different fact
// from "this is somebody's front door".
expect(buildFlowFields({ flow: FLOW.P2P, pickupAnchor: null }).pickup_source_type).toBe(
PICKUP_SOURCE_TYPE.CUSTOMER
);
});
it('should never put an unknown source type on the wire', () => {
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupSourceType: 'banana', pickupAnchor: baseAnchor });
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
});
it('should not name a base on a pickup that is not from one', () => {
const fields = buildFlowFields({
flow: FLOW.REVERSE,
pickupSourceType: PICKUP_SOURCE_TYPE.CUSTOMER,
pickupAnchor: baseAnchor
});
expect(fields.pickuphubid).toBeUndefined();
});
it('should prefer the type stored on the booking over geometry', () => {
// A booking collected at a base whose coordinates were later edited must
// keep reading as a base pickup — the row recorded what actually happened.
const c = classifyBooking(
{
bookingid: 1,
pickup_source_type: PICKUP_SOURCE_TYPE.HUB,
pickuphubid: HUB.hubid,
pickuplatitude: DOOR_A.lat,
pickuplongitude: DOOR_A.lng,
deliverylatitude: DOOR_B.lat,
deliverylongitude: DOOR_B.lng
},
anchors
);
expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
expect(c.pickupHubId).toBe(HUB.hubid);
expect(c.sourceTypeFromServer).toBe(true);
});
it('should classify an older booking from geometry when the row says nothing', () => {
const c = classifyBooking(
{
bookingid: 2,
pickuplatitude: HUB.latitude,
pickuplongitude: HUB.longitude,
deliverylatitude: DOOR_B.lat,
deliverylongitude: DOOR_B.lng
},
anchors
);
expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
expect(c.sourceTypeFromServer).toBe(false);
});
});
});

View File

@@ -0,0 +1,180 @@
import {
baseLine,
nextActionLabel,
routingReason,
routingRoute,
summariseRouting
} from '@/lib/routingSummary';
/**
* The routing block, turned into sentences.
*
* This is what a support agent reads to a rider who has rung asking why his
* screen says hand over at a base instead of deliver to the customer. Two
* things carry the weight here:
*
* 1. A PROJECTION MUST NOT READ AS A DECISION. Before pickup nothing has been
* routed; `decided: false` means the answer is what WILL happen. Stating a
* forecast as a fact is a wrong answer delivered confidently.
* 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens at this edge
* and nowhere else, and it never travels back up the wire.
*/
const HUB_ROUTED = {
pickup_source_type: 'customer',
pickup_source_name: 'Anitha R',
from_address: '12 Race Course Road, Coimbatore',
from_pincode: '641018',
to_address: '44 Mount Road, Chennai',
destination_pincode: '600002',
is_hyperlocal: false,
consignment_state: 'Created',
next_action: 'inward_at_hub',
next_hub: {
id: 1,
name: 'Coimbatore Hub',
address: '14 Avinashi Road, Peelamedu, Coimbatore',
pincode: '641004',
latitude: 11.0272,
longitude: 76.9905
},
inwardedat: null,
decided: true
};
const HYPERLOCAL = {
...HUB_ROUTED,
destination_pincode: '641004',
is_hyperlocal: true,
consignment_state: 'Out_for_Delivery',
next_action: 'deliver',
next_hub: null
};
describe('routingReason — why it is going that way', () => {
it('should say a base is involved when the areas differ', () => {
expect(routingReason(HUB_ROUTED)).toMatch(/through a base/i);
});
it('should say the rider carries it straight there when the areas match', () => {
expect(routingReason(HYPERLOCAL)).toMatch(/straight to the receiver/i);
});
it('should never say "hub" to an operator', () => {
// Internal vocabulary. Screens say base.
expect(routingReason(HUB_ROUTED).toLowerCase()).not.toContain('hub');
});
it('should return an empty string rather than throw when there is no routing block', () => {
// An older booking, or a backend that has not shipped this yet.
expect(routingReason(null)).toBe('');
expect(routingReason(undefined)).toBe('');
});
});
describe('routingRoute — the from → to line', () => {
it('should show both pincodes', () => {
expect(routingRoute(HUB_ROUTED)).toContain('641018');
expect(routingRoute(HUB_ROUTED)).toContain('600002');
});
it('should not caveat a decision that has actually been taken', () => {
expect(routingRoute(HUB_ROUTED)).not.toMatch(/not yet collected/i);
});
it('should mark an undecided booking as a forecast, not a fact', () => {
const undecided = { ...HUB_ROUTED, decided: false };
expect(routingRoute(undecided)).toMatch(/not yet collected/i);
expect(routingRoute(undecided)).toMatch(/will happen, not what has/i);
});
it('should show a dash for a pincode the booking never carried', () => {
// The delivery-pincode column is genuinely empty on plenty of rows; a blank
// gap in the line reads as a rendering bug rather than as missing data.
expect(routingRoute({ ...HUB_ROUTED, destination_pincode: '' })).toContain('—');
});
});
describe('nextActionLabel — what the rider does next', () => {
it('should translate every action the backend can send', () => {
expect(nextActionLabel('pickup')).toMatch(/collect/i);
expect(nextActionLabel('inward_at_hub')).toMatch(/base/i);
expect(nextActionLabel('start_delivery')).toMatch(/delivery run/i);
expect(nextActionLabel('deliver')).toMatch(/receiver/i);
expect(nextActionLabel('handed_to_hub')).toMatch(/handed over/i);
expect(nextActionLabel('none')).toMatch(/nothing further/i);
});
it('should render inward_at_hub as a BASE handover, never as a hub one', () => {
expect(nextActionLabel('inward_at_hub').toLowerCase()).not.toContain('hub');
});
it('should show an unknown action as itself rather than swallow it', () => {
// The backend may add actions. A blank row tells the operator nothing; the
// raw word at least tells them something new exists.
expect(nextActionLabel('return_to_sender')).toBe('return_to_sender');
});
it('should show a dash when there is no action at all', () => {
expect(nextActionLabel(undefined)).toBe('—');
expect(nextActionLabel('')).toBe('—');
});
});
describe('baseLine — naming the base', () => {
it('should read the base with its pincode', () => {
expect(baseLine(HUB_ROUTED.next_hub)).toBe('Coimbatore Hub · 641004');
});
it('should fall back to the name alone when a base has no pincode', () => {
expect(baseLine({ name: 'Coimbatore Hub' })).toBe('Coimbatore Hub');
});
it('should be null when no base is involved', () => {
// A hyperlocal parcel has no base. That is the correct answer, not a gap.
expect(baseLine(null)).toBeNull();
expect(baseLine({})).toBeNull();
});
});
describe('summariseRouting — everything the drawer renders', () => {
it('should carry the base, its address and the next step for a hub-routed parcel', () => {
const s = summariseRouting(HUB_ROUTED);
expect(s.base).toBe('Coimbatore Hub · 641004');
expect(s.baseAddress).toBe('14 Avinashi Road, Peelamedu, Coimbatore');
expect(s.nextAction).toMatch(/base/i);
expect(s.consignmentState).toBe('Created');
expect(s.decided).toBe(true);
});
it('should carry no base for a hyperlocal parcel', () => {
const s = summariseRouting(HYPERLOCAL);
expect(s.base).toBeNull();
expect(s.baseAddress).toBeNull();
expect(s.nextAction).toMatch(/receiver/i);
});
it('should surface the received-at time once the parcel is actually inwarded', () => {
const s = summariseRouting({
...HUB_ROUTED,
consignment_state: 'Inwarded_at_Hub',
next_action: 'handed_to_hub',
inwardedat: '2026-09-02T14:22:10Z'
});
expect(s.inwardedAt).toBe('2026-09-02T14:22:10Z');
expect(s.nextAction).toMatch(/handed over/i);
});
it('should show a dash for a parcel that has no consignment yet', () => {
const s = summariseRouting({ ...HUB_ROUTED, consignment_state: '', decided: false });
expect(s.consignmentState).toBe('—');
expect(s.decided).toBe(false);
});
it('should be null when the booking carries no routing block, so the panel hides', () => {
// An older backend, or a booking fetched before this shipped. The drawer
// renders nothing rather than a panel of dashes.
expect(summariseRouting(null)).toBeNull();
expect(summariseRouting(undefined)).toBeNull();
});
});