updates on the bookings page and updated ai and more thngs
This commit is contained in:
@@ -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
|
||||
|
||||
215
scripts/makeLogisticsTestSheet.cjs
Normal file
215
scripts/makeLogisticsTestSheet.cjs
Normal 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}`);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
|
||||
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx'));
|
||||
|
||||
const Orders = lazy(() => import('@/pages/doormile/orders/Orders'));
|
||||
const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings'));
|
||||
const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview'));
|
||||
const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
|
||||
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
|
||||
@@ -93,6 +94,8 @@ const AuthenticatedApp = () => {
|
||||
<Route path="orders/createorders" element={<MultipleOrders />} />
|
||||
<Route path="orders/preview" element={<OrdersPreview />} />
|
||||
|
||||
<Route path="bookings" element={<Bookings />} />
|
||||
|
||||
<Route path="deliveries" element={<Deliveries />} />
|
||||
|
||||
<Route path="tenants" element={<Tenants />} />
|
||||
|
||||
@@ -404,9 +404,12 @@ export const getBookings = async (pageno, pagesize) => {
|
||||
//
|
||||
// getBookings returns `response.data.data` — just the rows — which means no
|
||||
// caller can tell a full result from a truncated one. `pagesize` is capped at
|
||||
// 1000 server-side (express-console-api.md, "Conventions": default 500, cap
|
||||
// 1000), so any account with more than 1000 bookings silently gets a partial
|
||||
// list from a single call. That's tolerable for a paginated table (it fetches
|
||||
// 100 server-side, and the default is 20 — NOT "default 500, cap 1000", which
|
||||
// is what express-console-api.md said until it was corrected. Asking for 1000
|
||||
// returns 100 rows and an envelope whose own `pagesize` reads 100, so the
|
||||
// under-fetch is silent unless you check it. Any account with more than 100
|
||||
// bookings therefore gets a partial list from a single call. That's tolerable
|
||||
// for a paginated table (it fetches
|
||||
// the next page as you scroll) but NOT for anything that counts or sums, which
|
||||
// would report a confidently wrong number.
|
||||
//
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import { OpenToast } from './notify';
|
||||
import logger from '@/lib/logger';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { buildAnchors, classifyBooking } from '@/lib/orderFlow';
|
||||
import {
|
||||
getMilers,
|
||||
getMiler,
|
||||
@@ -699,6 +700,81 @@ export const notifyRider = async (
|
||||
// page already reads (orderheaderid, deliverycustomer, ridername, etc.) so
|
||||
// deliveries.js itself doesn't need a full rewrite — same compatibility-shim
|
||||
// approach as fetchAllRiders' `{details}` wrapper.
|
||||
// ==============================|| Facility anchors for flow classification ||============================== //
|
||||
//
|
||||
// classifyBooking needs the list of places that count as "a facility, not a
|
||||
// customer's door": Doormile's own hubs (GET /admin/hubs) plus each client's
|
||||
// own origins (GET /admin/tenants/:id/locations). Without the second list a
|
||||
// tenant's kitchen would classify as a customer pickup, which would relabel
|
||||
// every historical order on the board — so both are required, not optional.
|
||||
//
|
||||
// Cached for the life of the session because fetchDeliveries runs once PER
|
||||
// PAGE of bookings. Re-fetching a tenant's locations on every page of a
|
||||
// 2,000-order day would add one request per tenant per page for reference data
|
||||
// that changes about monthly.
|
||||
//
|
||||
// Every call is guarded. If an anchor list fails to load, classification
|
||||
// degrades to "everything is a hub pickup" — precisely what the console did
|
||||
// before this existed — rather than failing the page.
|
||||
let hubAnchorSourcePromise = null;
|
||||
const tenantLocationSourcePromises = new Map();
|
||||
|
||||
// Always resolves to an array. Guards the throw, the rejected promise AND the
|
||||
// call that returns nothing at all — this runs on the deliveries path, which is
|
||||
// the one thing fetchDeliveries cannot recover from, so a reference list that
|
||||
// misbehaves must degrade classification rather than fail the page.
|
||||
const safeAnchorList = async (fetcher) => {
|
||||
try {
|
||||
const result = await fetcher();
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadHubAnchorSources = () => {
|
||||
if (!hubAnchorSourcePromise) {
|
||||
hubAnchorSourcePromise = safeAnchorList(() => getHubs());
|
||||
}
|
||||
return hubAnchorSourcePromise;
|
||||
};
|
||||
|
||||
const loadTenantLocationSources = (tenantId) => {
|
||||
const key = String(tenantId);
|
||||
if (!tenantLocationSourcePromises.has(key)) {
|
||||
tenantLocationSourcePromises.set(key, safeAnchorList(() => getTenantLocations(tenantId)));
|
||||
}
|
||||
return tenantLocationSourcePromises.get(key);
|
||||
};
|
||||
|
||||
/** Drop the cached facility lists. Call after creating or editing a hub or a
|
||||
* tenant location, otherwise a newly-added kitchen keeps classifying its own
|
||||
* orders as customer pickups until the tab is reloaded. */
|
||||
export const resetFlowAnchorCache = () => {
|
||||
hubAnchorSourcePromise = null;
|
||||
tenantLocationSourcePromises.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* The facility anchors for a set of tenants.
|
||||
*
|
||||
* Exported because pages that read RAW bookings — Orders, for one — have to
|
||||
* classify with exactly the same facility list as the deliveries feed does.
|
||||
* Two pages deriving anchors from two different lists is how the same order
|
||||
* ends up labelled a customer pickup on one screen and a hub run on the other.
|
||||
*/
|
||||
export const fetchFlowAnchors = async (tenantIds = []) => {
|
||||
const ids = [...new Set((tenantIds || []).filter((id) => id != null))];
|
||||
const [hubs, locationLists] = await Promise.all([
|
||||
loadHubAnchorSources(),
|
||||
Promise.all(ids.map((id) => loadTenantLocationSources(id)))
|
||||
]);
|
||||
const tenantLocations = (locationLists || []).flatMap((list) => (Array.isArray(list) ? list : []));
|
||||
return buildAnchors({ hubs, tenantLocations });
|
||||
};
|
||||
|
||||
const loadFlowAnchors = (bookings) => fetchFlowAnchors((bookings || []).map((b) => b?.tenantid));
|
||||
|
||||
export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
// Every caller builds the key as
|
||||
// [name, appId, userid, status, startdate, enddate, rowsPerPage, …]
|
||||
@@ -819,6 +895,10 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
|
||||
const dispatched = (bookings || []).filter(isDispatched);
|
||||
|
||||
// Which end of each booking is a facility. Loaded from the cache, so this is
|
||||
// a no-op network-wise on every page after the first.
|
||||
const flowAnchors = await loadFlowAnchors(dispatched);
|
||||
|
||||
const rows = dispatched.map((b) => {
|
||||
const consignmentId = b.consignmentid ?? b.consignment_id;
|
||||
const consignment = consignmentId != null ? consignmentMap.get(String(consignmentId)) : null;
|
||||
@@ -844,6 +924,7 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
const cStatus = consignmentStatusFor(b, consignmentMap);
|
||||
const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
|
||||
const effectiveDeliveryStatus = deriveDeliveryStatus(b, consignmentMap);
|
||||
const flow = classifyBooking(b, flowAnchors);
|
||||
|
||||
const actualRiderName =
|
||||
miler?.resolvedName ||
|
||||
@@ -870,6 +951,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
consignmentid: consignmentId,
|
||||
hubid: b.hubid,
|
||||
sourcehubid: b.sourcehubid,
|
||||
// The hub that OWNS this order for zone filtering and reporting, which
|
||||
// is NOT the same question as "where is it collected from". On a customer
|
||||
// pickup the collection point is a doorstep and the owning hub is the one
|
||||
// the parcel is inbound to; on a P2P run neither end is a facility and
|
||||
// this stays null, leaving ZoneContext to fall back to its radius match.
|
||||
servicinghubid: flow.servicingHubId,
|
||||
tenantid: b.tenantid,
|
||||
tenantname: tenant?.tenantname || '',
|
||||
tenantsuburb: '',
|
||||
@@ -879,7 +966,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
tenantadress: tenant?.primaryemail || '',
|
||||
locationname: tenant?.tenantname || '',
|
||||
locationsuburb: '',
|
||||
pickupcustomer: tenant?.tenantname || '',
|
||||
// Which flow this order is, and what its collection point actually is.
|
||||
//
|
||||
// `pickupcustomer` used to be the TENANT NAME, which made every order of
|
||||
// a client claim the same collection point no matter where it was picked
|
||||
// up. Dispatch groups its kitchen pins on this, so N customer pickups for
|
||||
// one client collapsed into a single pin sitting on whichever of them
|
||||
// happened to be first in the array. It is now the real point: the
|
||||
// facility's name for a hub pickup, the doorstep for a customer pickup.
|
||||
ordertype: flow.flow,
|
||||
ordertypelabel: flow.flowLabel,
|
||||
pickupsource: flow.pickupSource,
|
||||
droptarget: flow.dropTarget,
|
||||
// The STABLE grouping key — one per facility, but one per BOOKING for a
|
||||
// customer pickup, because two customer doors are two different places.
|
||||
// Group on this, never on the display name: two hubs can share a name.
|
||||
pickuppointkey: flow.pickupPointKey,
|
||||
pickupcustomer: flow.pickupPointName || tenant?.tenantname || '',
|
||||
pickupcontactno: '',
|
||||
Pickupaddress: b.pickupaddress || '',
|
||||
pickupaddress: b.pickupaddress || '',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, MapPin, X } from 'lucide-react';
|
||||
import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react';
|
||||
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
|
||||
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
export const toPlace = (result) => standardizePlace(result);
|
||||
|
||||
export const geocodeAddress = async (address, options = {}) => {
|
||||
return await serviceGeocodeAddress(address, options);
|
||||
};
|
||||
|
||||
const useDebouncedCallback = (callback, delay) => {
|
||||
const timeoutRef = useRef(null);
|
||||
@@ -17,68 +22,14 @@ const useDebouncedCallback = (callback, delay) => {
|
||||
);
|
||||
};
|
||||
|
||||
const buildAddressComponents = (addr = {}) => {
|
||||
const components = [];
|
||||
const push = (longName, types) => {
|
||||
if (longName) components.push({ long_name: longName, short_name: longName, types });
|
||||
};
|
||||
push(addr.house_number, ['street_number']);
|
||||
push(addr.road || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_3']);
|
||||
push(addr.city || addr.town || addr.village, ['locality']);
|
||||
push(addr.state, ['administrative_area_level_1']);
|
||||
push(addr.country, ['country']);
|
||||
push(addr.postcode, ['postal_code']);
|
||||
return components;
|
||||
};
|
||||
|
||||
export const toPlace = (result) => ({
|
||||
formatted_address: result.display_name || '',
|
||||
name: result.display_name?.split(',')[0] || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => parseFloat(result.lat),
|
||||
lng: () => parseFloat(result.lon)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(result.address)
|
||||
});
|
||||
|
||||
const buildParams = (query, bias) => {
|
||||
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
|
||||
if (bias?.lat && bias?.lng) {
|
||||
const d = 0.5; // ~55km soft bias bounding box
|
||||
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
if (!address || !address.trim()) return null;
|
||||
try {
|
||||
const params = buildParams(address.trim(), bias);
|
||||
params.set('limit', '1');
|
||||
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const results = await res.json();
|
||||
if (!results?.length) return null;
|
||||
return toPlace(results[0]);
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const AddressAutocomplete = ({
|
||||
id,
|
||||
label,
|
||||
placeholder = 'Search address…',
|
||||
placeholder = 'Search address or locality…',
|
||||
value,
|
||||
onChange,
|
||||
onPlaceSelected,
|
||||
onOpenMapPin,
|
||||
bias,
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
@@ -90,14 +41,10 @@ const AddressAutocomplete = ({
|
||||
const [options, setOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
/* Which suggestion the arrow keys are on. -1 is "none highlighted", the state
|
||||
the list opens in, so Enter never picks a result the operator hasn't moved
|
||||
to. */
|
||||
const [activeOption, setActiveOption] = useState(-1);
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const activeRef = useRef(0);
|
||||
// Set by pick(); consumed by the search effect. See the comment there.
|
||||
const skipNextSearchRef = useRef(false);
|
||||
const listId = `${id || 'address'}-suggestions`;
|
||||
const optionId = (index) => `${listId}-option-${index}`;
|
||||
@@ -112,11 +59,7 @@ const AddressAutocomplete = ({
|
||||
|
||||
const fetchPredictions = useDebouncedCallback((query) => {
|
||||
const seq = ++activeRef.current;
|
||||
const params = buildParams(query, bias);
|
||||
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
getAddressSuggestions(query, { bias, limit: 6 })
|
||||
.then((results) => {
|
||||
if (seq !== activeRef.current) return;
|
||||
setLoading(false);
|
||||
@@ -128,19 +71,14 @@ const AddressAutocomplete = ({
|
||||
setLoading(false);
|
||||
setOptions([]);
|
||||
});
|
||||
}, 450);
|
||||
}, 220);
|
||||
|
||||
useEffect(() => {
|
||||
/* Picking a suggestion writes the resolved address back into inputValue,
|
||||
which used to re-trigger this search: the list reopened over the form the
|
||||
instant it was dismissed, and every selection cost a second Nominatim
|
||||
request against a service that asks for one per second. The pick is not a
|
||||
new query, so skip exactly one run. */
|
||||
if (skipNextSearchRef.current) {
|
||||
skipNextSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!inputValue || inputValue.length < 3) {
|
||||
if (!inputValue || inputValue.length < 2) {
|
||||
activeRef.current++;
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
@@ -151,8 +89,6 @@ const AddressAutocomplete = ({
|
||||
fetchPredictions(inputValue);
|
||||
}, [inputValue, biasKey]);
|
||||
|
||||
/* A fresh result set invalidates the highlight — otherwise index 2 of the old
|
||||
list silently becomes index 2 of the new one. */
|
||||
useEffect(() => {
|
||||
setActiveOption(-1);
|
||||
}, [options]);
|
||||
@@ -168,23 +104,16 @@ const AddressAutocomplete = ({
|
||||
}, []);
|
||||
|
||||
const pick = (option) => {
|
||||
const formatted = option.display_name || '';
|
||||
const formatted = option.formatted_address || option.name || '';
|
||||
skipNextSearchRef.current = true;
|
||||
activeRef.current++; // drop any in-flight response that would reopen the list
|
||||
activeRef.current++;
|
||||
setInputValue(formatted);
|
||||
onChange?.(formatted);
|
||||
onPlaceSelected?.(toPlace(option));
|
||||
onPlaceSelected?.(option);
|
||||
setIsOpen(false);
|
||||
setActiveOption(-1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Arrow keys, Enter and Escape over the suggestion list.
|
||||
*
|
||||
* Not a nicety: an order cannot be created from typed text — the submit gate
|
||||
* requires the coordinates that only picking a suggestion supplies — so
|
||||
* without this the whole create-order flow needed a mouse.
|
||||
*/
|
||||
const onKeyDown = (e) => {
|
||||
const listOpen = isOpen && options.length > 0;
|
||||
if (e.key === 'Escape') {
|
||||
@@ -200,8 +129,6 @@ const AddressAutocomplete = ({
|
||||
e.preventDefault();
|
||||
setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
|
||||
} else if (e.key === 'Enter' && activeOption >= 0) {
|
||||
// Only swallow Enter when a suggestion is actually highlighted, so Enter
|
||||
// on a bare query still submits the surrounding form.
|
||||
e.preventDefault();
|
||||
pick(options[activeOption]);
|
||||
}
|
||||
@@ -209,11 +136,25 @@ const AddressAutocomplete = ({
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className={`relative ${className}`} style={{ width: fullWidth ? '100%' : undefined }}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-xs font-semibold text-slate-800 mb-1">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-xs font-semibold text-slate-800">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{onOpenMapPin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMapPin}
|
||||
className="text-[11px] font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer transition-colors"
|
||||
title="Choose point on map"
|
||||
>
|
||||
<Compass className="w-3 h-3" />
|
||||
<span>Pin on Map</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
@@ -234,66 +175,94 @@ const AddressAutocomplete = ({
|
||||
onKeyDown(e);
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
}}
|
||||
className={`w-full h-9 pl-3 pr-8 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
|
||||
className={`w-full h-9 pl-3 pr-16 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</span>
|
||||
) : inputValue && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
onChange?.('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5 rounded-full hover:bg-slate-100"
|
||||
title="Clear address"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{/* Action icons on the right */}
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{loading ? (
|
||||
<span className="text-slate-400 p-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</span>
|
||||
) : inputValue && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
onChange?.('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer"
|
||||
title="Clear address"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{onOpenMapPin && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMapPin}
|
||||
className="text-slate-400 hover:text-black p-1 rounded-md hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
title="Open map to pin exact location"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && options.length > 0 && (
|
||||
<ul
|
||||
id={listId}
|
||||
className="absolute z-[9999] left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-xl max-h-60 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
|
||||
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
|
||||
role="listbox"
|
||||
aria-label="Address suggestions"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
/* The option role sits on the <li>, which is what aria-activedescendant
|
||||
points at; the button inside stays for the mouse. Without this the
|
||||
popup announced as an empty listbox. */
|
||||
<li
|
||||
key={option.place_id}
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={index === activeOption}
|
||||
className={index === activeOption ? 'bg-slate-100' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setActiveOption(index)}
|
||||
onClick={() => pick(option)}
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer group"
|
||||
{options.map((option, index) => {
|
||||
const isHighlight = index === activeOption;
|
||||
return (
|
||||
<li
|
||||
key={`${option.latitude}-${option.longitude}-${index}`}
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={isHighlight}
|
||||
className={isHighlight ? 'bg-slate-50' : undefined}
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 group-hover:text-black mt-0.5 shrink-0 transition-colors" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-slate-800 truncate">
|
||||
{option.display_name?.split(',')[0]}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setActiveOption(index)}
|
||||
onClick={() => pick(option)}
|
||||
className="w-full text-left px-3.5 py-2.5 hover:bg-slate-50/80 flex items-start gap-2.5 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-slate-100 group-hover:bg-black text-slate-500 group-hover:text-white flex items-center justify-center shrink-0 mt-0.5 transition-colors">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 line-clamp-1">
|
||||
{option.display_name}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-900 truncate">
|
||||
{option.name || option.formatted_address.split(',')[0]}
|
||||
</span>
|
||||
{option.suburb && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-slate-100 text-slate-600 shrink-0">
|
||||
{option.suburb}
|
||||
</span>
|
||||
)}
|
||||
{option.postcode && (
|
||||
<span className="text-[10px] font-mono text-slate-400 shrink-0">
|
||||
{option.postcode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
|
||||
{option.formatted_address}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
@@ -307,6 +276,7 @@ AddressAutocomplete.propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onPlaceSelected: PropTypes.func,
|
||||
onOpenMapPin: PropTypes.func,
|
||||
bias: PropTypes.object,
|
||||
fullWidth: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
|
||||
350
src/components/doormile/MapPinPickerModal.jsx
Normal file
350
src/components/doormile/MapPinPickerModal.jsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { Check, Crosshair, Loader2, MapPin, Search, X } from 'lucide-react';
|
||||
import { getAddressSuggestions, reverseGeocode } from '@/lib/geocodingService';
|
||||
|
||||
// Fix default leaflet marker icon
|
||||
const customMarkerIcon = new L.DivIcon({
|
||||
className: 'custom-pin-marker',
|
||||
html: `
|
||||
<div style="position: relative; display: flex; flex-direction: column; align-items: center; transform: translate(-50%, -100%);">
|
||||
<div style="background-color: #000000; color: #ffffff; padding: 6px; border-radius: 9999px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); border: 2px solid #ffffff;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="width: 2px; height: 8px; background-color: #000000;"></div>
|
||||
<div style="width: 6px; height: 6px; border-radius: 9999px; background-color: rgba(0,0,0,0.4);"></div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [32, 42],
|
||||
iconAnchor: [16, 42]
|
||||
});
|
||||
|
||||
// Map click listener component
|
||||
function LocationMarker({ position, onLocationChange }) {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
onLocationChange(e.latlng.lat, e.latlng.lng);
|
||||
}
|
||||
});
|
||||
|
||||
const markerRef = useRef(null);
|
||||
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
dragend() {
|
||||
const marker = markerRef.current;
|
||||
if (marker != null) {
|
||||
const latlng = marker.getLatLng();
|
||||
onLocationChange(latlng.lat, latlng.lng);
|
||||
}
|
||||
}
|
||||
}),
|
||||
[onLocationChange]
|
||||
);
|
||||
|
||||
return position ? (
|
||||
<Marker
|
||||
draggable
|
||||
eventHandlers={eventHandlers}
|
||||
position={[position.lat, position.lng]}
|
||||
ref={markerRef}
|
||||
icon={customMarkerIcon}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
LocationMarker.propTypes = {
|
||||
position: PropTypes.shape({
|
||||
lat: PropTypes.number,
|
||||
lng: PropTypes.number
|
||||
}),
|
||||
onLocationChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// Map center adjuster
|
||||
function ChangeMapView({ center, zoom }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (center && Number.isFinite(center[0]) && Number.isFinite(center[1])) {
|
||||
map.setView(center, zoom || map.getZoom());
|
||||
}
|
||||
}, [center, zoom, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
ChangeMapView.propTypes = {
|
||||
center: PropTypes.arrayOf(PropTypes.number),
|
||||
zoom: PropTypes.number
|
||||
};
|
||||
|
||||
export default function MapPinPickerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
initialLat,
|
||||
initialLng,
|
||||
initialAddress = '',
|
||||
title = 'Pin Exact Location on Map'
|
||||
}) {
|
||||
// Default coordinates (Bengaluru / Central fallback if none provided)
|
||||
const defaultLat = Number(initialLat) || 12.9716;
|
||||
const defaultLng = Number(initialLng) || 77.5946;
|
||||
|
||||
const [coords, setCoords] = useState({ lat: defaultLat, lng: defaultLng });
|
||||
const [mapCenter, setMapCenter] = useState([defaultLat, defaultLng]);
|
||||
const [zoomLevel, setZoomLevel] = useState(initialLat && initialLng ? 16 : 13);
|
||||
|
||||
const [resolvedPlace, setResolvedPlace] = useState(null);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
|
||||
// Search in modal
|
||||
const [searchQuery, setSearchQuery] = useState(initialAddress || '');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const lat = Number(initialLat) || 12.9716;
|
||||
const lng = Number(initialLng) || 77.5946;
|
||||
setCoords({ lat, lng });
|
||||
setMapCenter([lat, lng]);
|
||||
setZoomLevel(initialLat && initialLng ? 16 : 13);
|
||||
setSearchQuery(initialAddress || '');
|
||||
setSearchResults([]);
|
||||
handleCoordUpdate(lat, lng);
|
||||
}
|
||||
}, [isOpen, initialLat, initialLng, initialAddress]);
|
||||
|
||||
const handleCoordUpdate = async (lat, lng) => {
|
||||
setCoords({ lat, lng });
|
||||
setIsResolving(true);
|
||||
try {
|
||||
const place = await reverseGeocode(lat, lng);
|
||||
setResolvedPlace(place);
|
||||
} catch (e) {
|
||||
console.warn('Reverse geocode error:', e);
|
||||
} finally {
|
||||
setIsResolving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e?.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await getAddressSuggestions(searchQuery, { limit: 5 });
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
console.warn('Modal search failed:', err);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickSearchResult = (result) => {
|
||||
if (result.latitude && result.longitude) {
|
||||
setMapCenter([result.latitude, result.longitude]);
|
||||
setZoomLevel(16);
|
||||
handleCoordUpdate(result.latitude, result.longitude);
|
||||
setSearchResults([]);
|
||||
setSearchQuery(result.formatted_address);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrentLocation = () => {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setMapCenter([lat, lng]);
|
||||
setZoomLevel(17);
|
||||
handleCoordUpdate(lat, lng);
|
||||
},
|
||||
(err) => {
|
||||
console.warn('Geolocation error:', err);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!coords.lat || !coords.lng) return;
|
||||
const finalPlace = resolvedPlace || {
|
||||
formatted_address: initialAddress || `Coordinates (${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)})`,
|
||||
name: 'Selected Pin',
|
||||
latitude: coords.lat,
|
||||
longitude: coords.lng,
|
||||
suburb: '',
|
||||
city: '',
|
||||
postcode: ''
|
||||
};
|
||||
|
||||
onConfirm?.(finalPlace);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-3 sm:p-6 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
||||
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-3xl overflow-hidden animate-in zoom-in-95 duration-150 flex flex-col h-[90vh] max-h-[700px]">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center justify-between bg-white shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-black text-white flex items-center justify-center">
|
||||
<MapPin className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
|
||||
<p className="text-[11px] text-slate-500">Click anywhere or drag the pin to set exact coordinates</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search & Location Bar */}
|
||||
<div className="p-3 bg-slate-50 border-b border-slate-200 shrink-0 relative">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search area, landmark or street to fly there…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-9 pl-9 pr-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black shadow-2xs"
|
||||
/>
|
||||
{isSearching && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="h-9 px-3.5 bg-black hover:bg-neutral-800 text-white text-xs font-semibold rounded-lg flex items-center gap-1.5 transition-colors cursor-pointer shadow-2xs"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCurrentLocation}
|
||||
title="Use current GPS location"
|
||||
className="h-9 px-3 border border-slate-200 bg-white hover:bg-slate-100 text-slate-700 text-xs font-semibold rounded-lg flex items-center gap-1.5 transition-colors cursor-pointer shadow-2xs"
|
||||
>
|
||||
<Crosshair className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="hidden sm:inline">My GPS</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Search suggestions dropdown in modal */}
|
||||
{searchResults.length > 0 && (
|
||||
<ul className="absolute z-50 left-3 right-3 mt-1 bg-white border border-slate-200 rounded-xl shadow-xl max-h-48 overflow-y-auto divide-y divide-slate-100">
|
||||
{searchResults.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickSearchResult(item)}
|
||||
className="w-full text-left px-3.5 py-2 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-slate-800 truncate">{item.name}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">{item.formatted_address}</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Leaflet Map Area */}
|
||||
<div className="flex-1 relative bg-slate-100 overflow-hidden">
|
||||
<MapContainer
|
||||
center={mapCenter}
|
||||
zoom={zoomLevel}
|
||||
scrollWheelZoom
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<ChangeMapView center={mapCenter} zoom={zoomLevel} />
|
||||
<LocationMarker position={coords} onLocationChange={handleCoordUpdate} />
|
||||
</MapContainer>
|
||||
|
||||
{/* Pin Instruction Overlay */}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[400] bg-black/75 text-white text-[11px] font-medium px-3 py-1.5 rounded-full shadow-lg backdrop-blur-xs pointer-events-none flex items-center gap-1.5">
|
||||
<MapPin className="w-3 h-3 text-emerald-400" />
|
||||
<span>Click map or drag pin to position</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Resolved Address & Confirmation */}
|
||||
<div className="p-4 border-t border-slate-200 bg-white flex flex-col sm:flex-row items-center justify-between gap-3 shrink-0">
|
||||
<div className="min-w-0 flex-1 w-full text-left">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Selected Location</div>
|
||||
{isResolving ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-slate-400" />
|
||||
<span>Identifying street address…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs font-semibold text-slate-900 truncate mt-0.5">
|
||||
{resolvedPlace?.formatted_address || `${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] font-mono text-slate-400 mt-0.5">
|
||||
Lat: {coords.lat.toFixed(6)}, Lng: {coords.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 sm:flex-initial h-9 px-4 border border-slate-200 bg-white hover:bg-slate-50 text-slate-700 text-xs font-semibold rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 sm:flex-initial h-9 px-5 bg-black hover:bg-neutral-800 text-white text-xs font-bold rounded-lg flex items-center justify-center gap-1.5 shadow-sm transition-all cursor-pointer"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>Confirm Location</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MapPinPickerModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
initialLat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
initialLng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
initialAddress: PropTypes.string,
|
||||
title: PropTypes.string
|
||||
};
|
||||
@@ -42,6 +42,7 @@ export function DataTable({
|
||||
/* Selection — pass `selectedIds` to enable */
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
selectAllPages = true,
|
||||
|
||||
/* Pagination — pass `pageSize` to enable */
|
||||
pageSize: initialPageSize,
|
||||
@@ -138,15 +139,22 @@ export function DataTable({
|
||||
/* ── Selection ───────────────────────────────────────────────────────── */
|
||||
const selectable = selectedIds !== undefined;
|
||||
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
|
||||
|
||||
const allRowIds = React.useMemo(
|
||||
() => sortedRows.map((row, i) => getRowId(row, i)),
|
||||
[sortedRows, getRowId]
|
||||
);
|
||||
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
|
||||
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||
const someOnPageSelected = pageIds.some((id) => selected.has(id));
|
||||
|
||||
const targetIds = selectAllPages && !serverPaginated ? allRowIds : pageIds;
|
||||
const allTargetSelected = targetIds.length > 0 && targetIds.every((id) => selected.has(id));
|
||||
const someTargetSelected = targetIds.some((id) => selected.has(id));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allOnPageSelected) {
|
||||
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
|
||||
if (allTargetSelected) {
|
||||
onSelectionChange?.(selectedIds.filter((id) => !targetIds.includes(id)));
|
||||
} else {
|
||||
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
|
||||
onSelectionChange?.([...new Set([...selectedIds, ...targetIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,9 +210,13 @@ export function DataTable({
|
||||
{selectable && (
|
||||
<th scope="col" className={cn('w-10 px-4', cellPad)}>
|
||||
<Checkbox
|
||||
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
||||
checked={allTargetSelected ? true : someTargetSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all rows on this page"
|
||||
aria-label={
|
||||
selectAllPages && !serverPaginated
|
||||
? 'Select all rows'
|
||||
: 'Select all rows on this page'
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
@@ -8,76 +8,15 @@ import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
// Free OSM Nominatim geocoder — replaces Google Places Autocomplete /
|
||||
// react-geocode across the console. No API key required. Nominatim's usage
|
||||
// policy caps the public endpoint at ~1 req/sec, so every caller here is
|
||||
// debounced (500ms) and cancels stale in-flight predictions.
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
|
||||
|
||||
// Builds a Google-Places-shaped `address_components` array (each entry
|
||||
// `{ long_name, short_name, types: [] }`) from Nominatim's flat `address`
|
||||
// object, so existing call sites' `place.address_components.forEach(...)`
|
||||
// type-switch parsing keeps working unchanged.
|
||||
const buildAddressComponents = (addr = {}) => {
|
||||
const components = [];
|
||||
const push = (longName, types) => {
|
||||
if (longName) components.push({ long_name: longName, short_name: longName, types });
|
||||
};
|
||||
push(addr.house_number, ['street_number']);
|
||||
push(addr.road || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_3']);
|
||||
push(addr.city || addr.town || addr.village, ['locality']);
|
||||
push(addr.state, ['administrative_area_level_1']);
|
||||
push(addr.country, ['country']);
|
||||
push(addr.postcode, ['postal_code']);
|
||||
return components;
|
||||
};
|
||||
export const toPlace = (result) => standardizePlace(result);
|
||||
|
||||
// Shapes a Nominatim result close enough to a Google Places `place` object
|
||||
// (formatted_address, geometry.location.lat()/lng(), address_components)
|
||||
// that call sites need only mechanical edits, not rewrites.
|
||||
export const toPlace = (result) => ({
|
||||
formatted_address: result.display_name,
|
||||
name: result.display_name?.split(',')[0] || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => parseFloat(result.lat),
|
||||
lng: () => parseFloat(result.lon)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(result.address)
|
||||
});
|
||||
|
||||
const buildParams = (query, bias) => {
|
||||
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
|
||||
if (bias?.lat && bias?.lng) {
|
||||
const d = 0.5; // ~55km box around the operator's zone — soft bias, not a hard filter
|
||||
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
// Standalone forward-geocode helper — replaces `Geocode.fromAddress(address)`
|
||||
// for call sites that resolve a plain text address without a predictions
|
||||
// dropdown (e.g. re-geocoding on submit). Returns a `toPlace`-shaped object,
|
||||
// or null if nothing matched.
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
if (!address) return null;
|
||||
try {
|
||||
const params = buildParams(address, bias);
|
||||
params.set('limit', '1');
|
||||
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
const results = await res.json();
|
||||
if (!results?.length) return null;
|
||||
return toPlace(results[0]);
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
}
|
||||
export async function geocodeAddress(address, options = {}) {
|
||||
return await serviceGeocodeAddress(address, options);
|
||||
}
|
||||
|
||||
|
||||
// Address search-as-you-type — replaces Google Places Autocomplete across the
|
||||
// console.
|
||||
//
|
||||
@@ -121,9 +60,7 @@ const AddressAutocomplete = ({
|
||||
|
||||
const fetchPredictions = useDebouncedCallback((query) => {
|
||||
const seq = ++activeRef.current;
|
||||
const params = buildParams(query, bias);
|
||||
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
getAddressSuggestions(query, { bias, limit: 6 })
|
||||
.then((results) => {
|
||||
if (seq !== activeRef.current) return; // a newer keystroke won
|
||||
setLoading(false);
|
||||
@@ -135,10 +72,10 @@ const AddressAutocomplete = ({
|
||||
setLoading(false);
|
||||
setOptions([]);
|
||||
});
|
||||
}, 500);
|
||||
}, 250);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputValue) {
|
||||
if (!inputValue || inputValue.length < 2) {
|
||||
activeRef.current++; // invalidate any in-flight request
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
@@ -178,9 +115,10 @@ const AddressAutocomplete = ({
|
||||
);
|
||||
|
||||
const pick = (option) => {
|
||||
setInputValue(option.display_name || '');
|
||||
onChange?.(option.display_name || '');
|
||||
onPlaceSelected?.(toPlace(option));
|
||||
const formatted = option.formatted_address || option.name || '';
|
||||
setInputValue(formatted);
|
||||
onChange?.(formatted);
|
||||
onPlaceSelected?.(option);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -212,8 +150,8 @@ const AddressAutocomplete = ({
|
||||
|
||||
{isOpen && options.length > 0 && (
|
||||
<ul style={{ ...listStyle, listStyle: 'none', margin: 0, padding: 0 }} role="listbox" aria-label="Address suggestions">
|
||||
{options.map((option) => (
|
||||
<li key={option.place_id}>
|
||||
{options.map((option, idx) => (
|
||||
<li key={`${option.latitude}-${option.longitude}-${idx}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(option)}
|
||||
@@ -234,7 +172,7 @@ const AddressAutocomplete = ({
|
||||
}}
|
||||
>
|
||||
<Text display="block" size="sm" maxLines={2}>
|
||||
{option.display_name}
|
||||
{option.formatted_address || option.name}
|
||||
</Text>
|
||||
</button>
|
||||
</li>
|
||||
@@ -243,6 +181,7 @@ const AddressAutocomplete = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
AddressAutocomplete.propTypes = {
|
||||
|
||||
@@ -47,6 +47,7 @@ const LOGO_FILTER = {
|
||||
/** Destinations that sit directly on the header bar. */
|
||||
const NAV = [
|
||||
{ label: 'Dispatch', path: '/doormile/dispatch' },
|
||||
{ label: 'Bookings', path: '/doormile/bookings' },
|
||||
{ label: 'Orders', path: '/doormile/orders' },
|
||||
{ label: 'Deliveries', path: '/doormile/deliveries' },
|
||||
{ label: 'Milers', path: '/doormile/milers' },
|
||||
|
||||
@@ -146,7 +146,16 @@ export function ZoneProvider({ children }) {
|
||||
}
|
||||
|
||||
// 1. Direct hub/location id match
|
||||
//
|
||||
// `servicinghubid` is queries.js's answer to "which hub OWNS this order",
|
||||
// which is a different question from "where is it collected from" and the
|
||||
// only one that stays answerable once the pickup is a customer's doorstep.
|
||||
// It is consulted first, and only for staff sessions: a tenant's zones are
|
||||
// its own locations, so a hub id compared against a tenantlocationid would
|
||||
// match on a bare numeric collision and file the order under a zone that
|
||||
// has nothing to do with it.
|
||||
const itemHubId =
|
||||
(!isTenantUser ? item.servicinghubid : null) ??
|
||||
item.hubid ??
|
||||
item.sourcehubid ??
|
||||
item.applocationid ??
|
||||
|
||||
@@ -4,11 +4,19 @@ import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
/**
|
||||
* Reading bookings for the assistant.
|
||||
*
|
||||
* `GET /admin/bookings` has no date, status or tenant filter and caps `pagesize`
|
||||
* at 1000 server-side, so every question is answered by draining pages and
|
||||
* filtering here. A single `getBookings(1, 1000)` silently under-reports the
|
||||
* moment an account passes 1000 lifetime bookings, which is why nothing in the
|
||||
* assistant calls it.
|
||||
* `GET /admin/bookings` caps `pagesize` at 100 server-side — NOT 1000, which is
|
||||
* what this comment claimed for a long time and what `express-console-api.md`
|
||||
* documented. A page is 100 rows, so MAX_PAGES x BULK_PAGESIZE is not the
|
||||
* reachable total: 12 pages is 1200 rows, not 12,000.
|
||||
*
|
||||
* It IS tenant-scoped, and it does accept `?status=` — but as an exact,
|
||||
* case-sensitive, single-value match against the stored capitalised enum
|
||||
* (`Pending_Pickup`). The status GROUPS this console renders as tabs each cover
|
||||
* several values in lowercase, so the server filter cannot express a tab and
|
||||
* the grouping still happens here. There is no date filter.
|
||||
*
|
||||
* The list is ordered `bookingid DESC`, so page 1 is the newest page and a
|
||||
* drain that stops early keeps the most recent bookings rather than the oldest.
|
||||
*
|
||||
* Every scan returns `{ rows, truncated, scanned, pagesFetched, total }` rather
|
||||
* than a bare array, and **`truncated` is not optional to handle**. A count
|
||||
|
||||
54
src/lib/bookingDrops.js
Normal file
54
src/lib/bookingDrops.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// ============================================================================
|
||||
// One pickup, many drops.
|
||||
//
|
||||
// A customer-app booking is ONE pickup carrying N destinations — never N
|
||||
// bookings. `GET /admin/bookings` reports how many each row has
|
||||
// (`destinationcount`, `totalpackagecount`) without shipping the destination
|
||||
// array, which is fetched only when a row is opened.
|
||||
//
|
||||
// Every helper here has to survive three shapes of row, because all three
|
||||
// reach the Orders table at once:
|
||||
//
|
||||
// 1. The console running AHEAD of the backend that adds these fields, where
|
||||
// `destinationcount` is undefined. Nothing may change for that row.
|
||||
// 2. An ordinary single-drop booking, which is most of them.
|
||||
// 3. A console-created booking, which has no bookingdestinations rows at all
|
||||
// and reports 0 — distinct from 1, and rendered the same as case 1.
|
||||
// ============================================================================
|
||||
|
||||
export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
||||
|
||||
/**
|
||||
* How many drops one pickup carries, or 0 when the row cannot say.
|
||||
*
|
||||
* `Number(undefined) || 0` collapses the absent-field case to 0, which is what
|
||||
* keeps a console deployed before the backend rendering exactly as it does
|
||||
* today.
|
||||
*/
|
||||
export const dropCountOf = (row) => Number(row?.destinationcount) || 0;
|
||||
|
||||
/**
|
||||
* Whether this pickup created more than one shipment.
|
||||
*
|
||||
* The single gate for every multi-drop behaviour on the Orders page: the
|
||||
* summary line, and — more importantly — the refusal to render the booking's
|
||||
* own `consignmentstatus` as the row's status. Deliberately `> 1`, never
|
||||
* `>= 1`: "1 destination · 1 package" on every B2C row is noise on the common
|
||||
* case, and a single-drop booking's consignment status IS the booking's.
|
||||
*/
|
||||
export const isMultiDrop = (row) => dropCountOf(row) > 1;
|
||||
|
||||
/**
|
||||
* The collapsed row's summary — "3 destinations · 4 packages".
|
||||
*
|
||||
* Only meaningful when `isMultiDrop(row)`; callers gate on that.
|
||||
*/
|
||||
export const dropSummary = (row) => {
|
||||
const drops = dropCountOf(row);
|
||||
/* Packages fall back to the destination count rather than to zero. Every
|
||||
destination carries at least one package, so "3 destinations · 0 packages"
|
||||
describes a booking the data cannot actually produce — and would be the
|
||||
reading if the count arrived without the sum. */
|
||||
const packages = Number(row?.totalpackagecount) || drops;
|
||||
return `${plural(drops, 'destination')} · ${plural(packages, 'package')}`;
|
||||
};
|
||||
120
src/lib/bulkOrderPayload.js
Normal file
120
src/lib/bulkOrderPayload.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// ==============================|| Bulk sheet row → booking payload ||============================== //
|
||||
//
|
||||
// The transform that turns one processed spreadsheet row into one
|
||||
// POST /admin/expressbooking body. It lived inline inside MultipleOrders.jsx,
|
||||
// where nothing could reach it: the only way to find out what a tenant's sheet
|
||||
// actually produced was to upload it and read the orders afterwards.
|
||||
//
|
||||
// Pulled out so the real transform can be driven by a test against a real
|
||||
// tenant file. The page imports it; nothing is reimplemented in either place,
|
||||
// so a test that passes is a statement about what the page does, not about a
|
||||
// copy of it that happens to agree today.
|
||||
|
||||
import { PICKUP_SOURCE, buildFlowFields, flowForDraft } from '@/lib/orderFlow';
|
||||
|
||||
export const BULK_SERVICE_OPTION = 'Normal';
|
||||
export const DEFAULT_ITEM_CATEGORY = 'General';
|
||||
|
||||
/**
|
||||
* The collection point for one row.
|
||||
*
|
||||
* `__pickup` travels on the ROW, not on the page. A single file can carry
|
||||
* several origins — a real one carries three home kitchens across thirteen
|
||||
* drops — and folding those onto one shared pickup silently re-addresses every
|
||||
* row that did not come from it.
|
||||
*/
|
||||
export const rowPickupOf = (row, sharedPickup) => row?.__pickup || sharedPickup || null;
|
||||
|
||||
/**
|
||||
* Parcels for one row.
|
||||
*
|
||||
* A parcel entry has NO quantity field: N items means N entries, which is how
|
||||
* the rest of the app reads it back (`Quantity: b.parcels?.length`, see
|
||||
* queries.js). This is why quantity has to expand here rather than travel as a
|
||||
* number — a single entry for a five-item order reports as one parcel
|
||||
* everywhere downstream, and the rider is told to collect one bag.
|
||||
*/
|
||||
export const buildParcels = (row) => {
|
||||
const count = Math.max(1, Math.floor(Number(row?.quantity)) || 1);
|
||||
const description = String(row?.description || '').trim() || 'Order';
|
||||
const declaredvalue = Number(row?.totalcharge) || 0;
|
||||
return Array.from({ length: count }, () => ({
|
||||
itemcategory: DEFAULT_ITEM_CATEGORY,
|
||||
itemdescription: description,
|
||||
declaredvalue
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* One processed row → one booking payload.
|
||||
*
|
||||
* `row` is a drop as MultipleOrders holds it after processing: sheet fields
|
||||
* mapped to internal names, coordinates resolved, `distance`/`totalcharge`
|
||||
* computed, and `__pickup` attached when the run collects from customers.
|
||||
*/
|
||||
export const buildBulkBookingPayload = ({
|
||||
row,
|
||||
sharedPickup,
|
||||
tenantId,
|
||||
tenantLocationId,
|
||||
deliverytime,
|
||||
notes = '',
|
||||
anchors = []
|
||||
}) => {
|
||||
const origin = rowPickupOf(row, sharedPickup) || {};
|
||||
|
||||
const flow = flowForDraft({
|
||||
// A row that resolved its own collection point is a customer pickup. One
|
||||
// that fell back to the shared location is not — it really is being
|
||||
// collected at the hub, and labelling it otherwise would put a doorstep
|
||||
// pin on a facility. This is only the HINT: the coordinates below outrank
|
||||
// it, so a sender address that turns out to be the tenant's own registered
|
||||
// kitchen is booked as the hub run it is.
|
||||
pickupSource: row?.__pickup ? PICKUP_SOURCE.CUSTOMER : PICKUP_SOURCE.HUB,
|
||||
pickupLat: origin.latitude,
|
||||
pickupLng: origin.longitude,
|
||||
dropLat: row?.latitude,
|
||||
dropLng: row?.longitude,
|
||||
anchors
|
||||
});
|
||||
|
||||
return {
|
||||
tenantid: Number(tenantId),
|
||||
pickupaddress: origin.address || '',
|
||||
pickuppincode: String(origin.pincode || origin.postcode || ''),
|
||||
pickupcity: origin.city || '',
|
||||
pickuplatitude: Number(origin.latitude) || 0,
|
||||
pickuplongitude: Number(origin.longitude) || 0,
|
||||
customer_phone: row?.contactno != null ? String(row.contactno) : '',
|
||||
customer_name: row?.firstname || '',
|
||||
deliveryaddress: row?.address || '',
|
||||
deliverypincode: row?.postcode != null ? String(row.postcode) : '',
|
||||
deliverycity: row?.city || '',
|
||||
deliverylatitude: Number(row?.latitude) || 0,
|
||||
deliverylongitude: Number(row?.longitude) || 0,
|
||||
deliverytime,
|
||||
service_option: BULK_SERVICE_OPTION,
|
||||
finalprice: Number(row?.totalcharge) || 0,
|
||||
notes: notes || '',
|
||||
...buildFlowFields({
|
||||
flow: flow.flow,
|
||||
// The facility we collect from, then the one we deliver to, then the
|
||||
// selected location's own hub. A P2P row matches neither end, and falls
|
||||
// through to the third — which is right: the run still belongs to the
|
||||
// hub servicing it, even though no facility is a stop on the journey.
|
||||
servicingHubId:
|
||||
flow.originAnchor?.hubid ?? flow.destAnchor?.hubid ?? sharedPickup?.hubid ?? null,
|
||||
// Same reasoning as the single-order form: the row records what kind of
|
||||
// place it is collected from, rather than leaving every reader to
|
||||
// re-derive it from two coordinates.
|
||||
pickupAnchor: flow.originAnchor,
|
||||
tenantLocationId
|
||||
}),
|
||||
parcels: buildParcels(row)
|
||||
};
|
||||
};
|
||||
|
||||
/** The whole run. Kept as a thin map so the page and the test agree on the
|
||||
* ordering and count of what goes on the wire, not just on each row's shape. */
|
||||
export const buildBulkBookingPayloads = ({ rows = [], ...shared }) =>
|
||||
rows.map((row) => buildBulkBookingPayload({ row, ...shared }));
|
||||
110
src/lib/customerAppBookings.js
Normal file
110
src/lib/customerAppBookings.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// ==============================|| Customer-app bookings ||============================== //
|
||||
//
|
||||
// Which bookings came from the B2C customer app, as opposed to being created in
|
||||
// this console.
|
||||
//
|
||||
// `bookingsource` is the only field that separates them, and its stored value is
|
||||
// `"Customer_App"` vs `"CRM_Console"`. Note the second one: the outward API and
|
||||
// routes were renamed from "CRM" to "express", but the stored column value was
|
||||
// deliberately left alone — it is existing data, not a label. Matching on
|
||||
// "express" here would find nothing.
|
||||
//
|
||||
// The predicate lives in lib/ rather than inline in the page so the rule is
|
||||
// stated once and can be tested on its own.
|
||||
|
||||
export const BOOKING_SOURCE = Object.freeze({
|
||||
CUSTOMER_APP: 'Customer_App',
|
||||
CONSOLE: 'CRM_Console',
|
||||
});
|
||||
|
||||
/**
|
||||
* True when a booking was made by a customer in the app.
|
||||
*
|
||||
* Matched case-insensitively and trimmed, because this value is compared against
|
||||
* a raw database column rather than a validated enum, and a stray space would
|
||||
* silently empty the page.
|
||||
*
|
||||
* A booking with NO bookingsource is not counted. The column defaults to
|
||||
* `Customer_App` at the database level, so a blank is almost certainly a console
|
||||
* booking written before the column existed — and quietly filing unknown rows
|
||||
* under "customer app" would overstate B2C volume, which is a number people
|
||||
* make decisions on.
|
||||
*/
|
||||
export const isCustomerAppBooking = (booking) =>
|
||||
String(booking?.bookingsource || '').trim().toLowerCase() ===
|
||||
BOOKING_SOURCE.CUSTOMER_APP.toLowerCase();
|
||||
|
||||
/** Every customer-app booking in a list, newest first. */
|
||||
export const customerAppBookings = (bookings) =>
|
||||
(bookings || [])
|
||||
.filter(isCustomerAppBooking)
|
||||
.sort((a, b) => (Number(b?.bookingid) || 0) - (Number(a?.bookingid) || 0));
|
||||
|
||||
/**
|
||||
* The price a booking was quoted.
|
||||
*
|
||||
* This is the same row `GET /customer/bookings/{id}/price` returns — that
|
||||
* endpoint reads the booking's newest `BookingServiceOption`, which is already
|
||||
* preloaded onto every admin booking row. So the console needs no separate call
|
||||
* for it, and there is no state where the list and the quote disagree.
|
||||
*/
|
||||
export const serviceOptionOf = (booking) => {
|
||||
const options = booking?.serviceoptions || [];
|
||||
if (options.length === 0) return null;
|
||||
// Newest wins, matching the backend's `ORDER BY createdat DESC LIMIT 1`.
|
||||
return [...options].sort(
|
||||
(a, b) => new Date(b?.createdat || 0) - new Date(a?.createdat || 0)
|
||||
)[0];
|
||||
};
|
||||
|
||||
export const quotedPrice = (booking) => Number(serviceOptionOf(booking)?.estimatedprice) || 0;
|
||||
export const serviceType = (booking) => serviceOptionOf(booking)?.servicetype || '';
|
||||
|
||||
/**
|
||||
* The tabs on the page: Created, Rider Assigned, Delivered, Cancelled, and All.
|
||||
*/
|
||||
export const BOOKING_TABS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'created', label: 'Created' },
|
||||
{ value: 'assigned', label: 'Rider Assigned' },
|
||||
{ value: 'delivered', label: 'Delivered' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
export const isBookingInTab = (booking, tabValue) => {
|
||||
const status = String(booking?.status || '').trim().toLowerCase();
|
||||
const consignmentStatus = String(booking?.consignmentstatus || booking?.consignment_status || '').trim().toLowerCase();
|
||||
const hasRider = Boolean(booking?.assignedmileruserid || booking?.mileruserid);
|
||||
|
||||
if (tabValue === 'all') return true;
|
||||
|
||||
if (tabValue === 'cancelled') {
|
||||
return status === 'cancelled' || consignmentStatus === 'cancelled';
|
||||
}
|
||||
|
||||
if (tabValue === 'delivered') {
|
||||
return status === 'delivered' || consignmentStatus === 'delivered';
|
||||
}
|
||||
|
||||
if (tabValue === 'created') {
|
||||
if (status === 'cancelled' || consignmentStatus === 'cancelled' || status === 'delivered' || consignmentStatus === 'delivered') {
|
||||
return false;
|
||||
}
|
||||
// Unassigned created / pending pickup orders
|
||||
return (status === 'created' || status === 'pending_pickup') && !hasRider;
|
||||
}
|
||||
|
||||
if (tabValue === 'assigned') {
|
||||
if (status === 'cancelled' || consignmentStatus === 'cancelled' || status === 'delivered' || consignmentStatus === 'delivered') {
|
||||
return false;
|
||||
}
|
||||
return hasRider || status === 'miler_assigned' || status === 'pickup_scheduled' || status === 'picked_up' || status === 'converted_to_consignment' || status === 'out_for_delivery' || status === 'inwarded_at_hub';
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Rows for one tab. */
|
||||
export const bookingsForTab = (bookings, tabValue) => {
|
||||
return (bookings || []).filter((b) => isBookingInTab(b, tabValue));
|
||||
};
|
||||
@@ -259,6 +259,27 @@ export const useHubs = (options) =>
|
||||
export const useHub = (id, options) =>
|
||||
useQuery({ queryKey: KEYS.hub(id), queryFn: () => api.getHub(id), enabled: !!id, ...options });
|
||||
|
||||
/**
|
||||
* The facility list used to decide which end of a booking is a hub.
|
||||
*
|
||||
* Lives here rather than in each page so every screen classifies against the
|
||||
* SAME facilities. A page that built its own list from hubs alone would call
|
||||
* every client's kitchen a customer pickup, and the same order would then carry
|
||||
* different labels on Orders and on Dispatch.
|
||||
*
|
||||
* `tenantIds` is sorted into the cache key so the same set of tenants in a
|
||||
* different order is one cache entry, not two.
|
||||
*/
|
||||
export const useFlowAnchors = (tenantIds = [], options) => {
|
||||
const ids = [...new Set((tenantIds || []).filter((id) => id != null).map(String))].sort();
|
||||
return useQuery({
|
||||
queryKey: ['doormile', 'flowAnchors', ids.join(',')],
|
||||
queryFn: () => api.fetchFlowAnchors(ids),
|
||||
staleTime: 5 * 60_000,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateHub = () =>
|
||||
useDoormileMutation({ mutationFn: api.createHub, invalidates: [KEYS.hubs], successMessage: 'Hub created' });
|
||||
|
||||
|
||||
352
src/lib/geocodingService.js
Normal file
352
src/lib/geocodingService.js
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Enterprise Geocoding & Address Autocomplete Service
|
||||
*
|
||||
* Supports multi-provider hierarchy:
|
||||
* 1. Ola Maps Places Autocomplete (if VITE_OLA_MAPS_API_KEY is configured)
|
||||
* 2. Google Places API (if VITE_GOOGLE_MAPS_API_KEY is configured)
|
||||
* 3. Photon (Komoot OSM - high-speed, typo-tolerant, sub-100ms autocomplete)
|
||||
* 4. Nominatim (OpenStreetMap standard geocoder with country lock)
|
||||
*/
|
||||
|
||||
const getEnvVar = (key) => {
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[key]) {
|
||||
return import.meta.env[key];
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (typeof process !== 'undefined' && process.env && process.env[key]) {
|
||||
return process.env[key];
|
||||
}
|
||||
} catch {}
|
||||
return '';
|
||||
};
|
||||
|
||||
const OLA_MAPS_KEY = getEnvVar('VITE_OLA_MAPS_API_KEY');
|
||||
|
||||
const PHOTON_API_URL = 'https://photon.komoot.io/api';
|
||||
const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse';
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse';
|
||||
|
||||
/**
|
||||
* Standardize place result into unified format for Doormile order forms
|
||||
*/
|
||||
export const standardizePlace = ({
|
||||
formatted_address = '',
|
||||
name = '',
|
||||
suburb = '',
|
||||
city = '',
|
||||
state = '',
|
||||
postcode = '',
|
||||
latitude = null,
|
||||
longitude = null,
|
||||
provider = 'unknown',
|
||||
raw = null
|
||||
}) => {
|
||||
const latNum = latitude != null ? Number(latitude) : null;
|
||||
const lngNum = longitude != null ? Number(longitude) : null;
|
||||
|
||||
return {
|
||||
formatted_address: formatted_address || name,
|
||||
name: name || formatted_address.split(',')[0] || '',
|
||||
suburb: suburb || '',
|
||||
city: city || '',
|
||||
state: state || '',
|
||||
postcode: postcode || '',
|
||||
latitude: Number.isFinite(latNum) ? latNum : null,
|
||||
longitude: Number.isFinite(lngNum) ? lngNum : null,
|
||||
provider,
|
||||
raw,
|
||||
// Google Places / Legacy compatibility interface
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => (Number.isFinite(latNum) ? latNum : 0),
|
||||
lng: () => (Number.isFinite(lngNum) ? lngNum : 0)
|
||||
}
|
||||
},
|
||||
address_components: [
|
||||
...(suburb ? [{ long_name: suburb, short_name: suburb, types: ['sublocality_level_1', 'sublocality'] }] : []),
|
||||
...(city ? [{ long_name: city, short_name: city, types: ['locality'] }] : []),
|
||||
...(state ? [{ long_name: state, short_name: state, types: ['administrative_area_level_1'] }] : []),
|
||||
...(postcode ? [{ long_name: postcode, short_name: postcode, types: ['postal_code'] }] : [])
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 1. Photon (Komoot OSM) Search - Fast, typo-tolerant, ideal for real-time keystroke suggestions
|
||||
*/
|
||||
async function searchPhoton(query, { bias, limit = 6 } = {}) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
lang: 'en'
|
||||
});
|
||||
|
||||
if (bias?.lat && bias?.lng) {
|
||||
params.set('lat', String(bias.lat));
|
||||
params.set('lon', String(bias.lng));
|
||||
}
|
||||
|
||||
const res = await fetch(`${PHOTON_API_URL}?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!data?.features || !Array.isArray(data.features)) return [];
|
||||
|
||||
return data.features.map((item) => {
|
||||
const p = item.properties || {};
|
||||
const [lon, lat] = item.geometry?.coordinates || [null, null];
|
||||
|
||||
const parts = [
|
||||
p.name,
|
||||
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
|
||||
p.district || p.suburb || p.neighbourhood || p.locality,
|
||||
p.city,
|
||||
p.state,
|
||||
p.postcode,
|
||||
p.country
|
||||
].filter(Boolean);
|
||||
|
||||
// Remove consecutive duplicates in address string
|
||||
const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx);
|
||||
const formatted = uniqueParts.join(', ');
|
||||
|
||||
return standardizePlace({
|
||||
formatted_address: formatted || p.name || '',
|
||||
name: p.name || formatted.split(',')[0],
|
||||
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
|
||||
city: p.city || p.county || '',
|
||||
state: p.state || '',
|
||||
postcode: p.postcode || '',
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
provider: 'photon',
|
||||
raw: item
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Geocoding] Photon search failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Nominatim Search - Fallback with countrycodes=in & addressdetails
|
||||
*/
|
||||
async function searchNominatim(query, { bias, country = 'in', limit = 5 } = {}) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: String(limit)
|
||||
});
|
||||
|
||||
if (country) {
|
||||
params.set('countrycodes', country.toLowerCase());
|
||||
}
|
||||
|
||||
if (bias?.lat && bias?.lng) {
|
||||
const d = 0.5; // ~55km bounding box
|
||||
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': 'en-GB,en;q=0.9',
|
||||
'User-Agent': 'DoormileConsole/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
const results = await res.json();
|
||||
if (!Array.isArray(results)) return [];
|
||||
|
||||
return results.map((r) => {
|
||||
const addr = r.address || {};
|
||||
const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '';
|
||||
const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || '';
|
||||
const state = addr.state || '';
|
||||
const postcode = addr.postcode || '';
|
||||
|
||||
return standardizePlace({
|
||||
formatted_address: r.display_name || '',
|
||||
name: r.display_name?.split(',')[0] || '',
|
||||
suburb,
|
||||
city,
|
||||
state,
|
||||
postcode,
|
||||
latitude: r.lat,
|
||||
longitude: r.lon,
|
||||
provider: 'nominatim',
|
||||
raw: r
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Geocoding] Nominatim search failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Ola Maps Places Autocomplete (if key configured)
|
||||
*/
|
||||
async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
|
||||
if (!OLA_MAPS_KEY) return [];
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
input: query,
|
||||
api_key: OLA_MAPS_KEY
|
||||
});
|
||||
if (bias?.lat && bias?.lng) {
|
||||
params.set('location', `${bias.lat},${bias.lng}`);
|
||||
params.set('radius', '50000');
|
||||
}
|
||||
|
||||
const res = await fetch(`https://api.olamaps.io/places/v1/autocomplete?${params.toString()}`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!data?.predictions || !Array.isArray(data.predictions)) return [];
|
||||
|
||||
return data.predictions.slice(0, limit).map((p) => {
|
||||
const lat = p.geometry?.location?.lat;
|
||||
const lng = p.geometry?.location?.lng;
|
||||
return standardizePlace({
|
||||
formatted_address: p.description || '',
|
||||
name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '',
|
||||
suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
provider: 'olamaps',
|
||||
raw: p
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Geocoding] Ola Maps autocomplete failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary Address Autocomplete Dispatcher
|
||||
* Queries fastest/best provider first, then falls back seamlessly.
|
||||
*/
|
||||
export async function getAddressSuggestions(query, options = {}) {
|
||||
const cleanQuery = (query || '').trim();
|
||||
if (!cleanQuery || cleanQuery.length < 2) return [];
|
||||
|
||||
// 1. Try Ola Maps if API key is present
|
||||
if (OLA_MAPS_KEY) {
|
||||
const olaResults = await searchOlaMaps(cleanQuery, options);
|
||||
if (olaResults.length > 0) return olaResults;
|
||||
}
|
||||
|
||||
// 2. High-speed Photon OSM autocomplete (sub-100ms, typo tolerant)
|
||||
const photonResults = await searchPhoton(cleanQuery, options);
|
||||
if (photonResults.length > 0) return photonResults;
|
||||
|
||||
// 3. Fallback to scoped Nominatim
|
||||
const nominatimResults = await searchNominatim(cleanQuery, options);
|
||||
return nominatimResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse Geocode: Convert Coordinates to structured address
|
||||
*/
|
||||
export async function reverseGeocode(lat, lng) {
|
||||
if (lat == null || lng == null) return null;
|
||||
|
||||
// 1. Try Photon reverse first
|
||||
try {
|
||||
const res = await fetch(`${PHOTON_REVERSE_URL}?lat=${lat}&lon=${lng}`, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data?.features?.length > 0) {
|
||||
const item = data.features[0];
|
||||
const p = item.properties || {};
|
||||
const parts = [
|
||||
p.name,
|
||||
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
|
||||
p.district || p.suburb || p.neighbourhood || p.locality,
|
||||
p.city,
|
||||
p.state,
|
||||
p.postcode,
|
||||
p.country
|
||||
].filter(Boolean);
|
||||
const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', ');
|
||||
|
||||
return standardizePlace({
|
||||
formatted_address: formatted,
|
||||
name: p.name || formatted.split(',')[0],
|
||||
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
|
||||
city: p.city || p.county || '',
|
||||
state: p.state || '',
|
||||
postcode: p.postcode || '',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
provider: 'photon_reverse'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Geocoding] Photon reverse failed, trying Nominatim:', e);
|
||||
}
|
||||
|
||||
// 2. Fallback to Nominatim reverse
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${NOMINATIM_REVERSE_URL}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': 'en-GB,en;q=0.9',
|
||||
'User-Agent': 'DoormileConsole/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const r = await res.json();
|
||||
const addr = r.address || {};
|
||||
return standardizePlace({
|
||||
formatted_address: r.display_name || '',
|
||||
name: r.display_name?.split(',')[0] || '',
|
||||
suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '',
|
||||
city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '',
|
||||
state: addr.state || '',
|
||||
postcode: addr.postcode || '',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
provider: 'nominatim_reverse'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Geocoding] Reverse geocode failed completely:', err);
|
||||
}
|
||||
|
||||
return standardizePlace({
|
||||
formatted_address: `Location (${Number(lat).toFixed(5)}, ${Number(lng).toFixed(5)})`,
|
||||
name: 'Selected Pin',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
provider: 'coordinates_only'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct forward geocode for single address string (used by bulk uploads / fallback)
|
||||
*/
|
||||
export async function geocodeAddress(address, options = {}) {
|
||||
const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 });
|
||||
return suggestions.length > 0 ? suggestions[0] : null;
|
||||
}
|
||||
62
src/lib/hubForm.js
Normal file
62
src/lib/hubForm.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// ==============================|| Hub (base) form validation ||============================== //
|
||||
//
|
||||
// A hub in this console is a BASE to a rider. The backend hands six fields to
|
||||
// the rider app as the place to go — id, name, address, pincode, latitude,
|
||||
// longitude — and the app uses them to title the stop, show the gate address,
|
||||
// and drive Navigate.
|
||||
//
|
||||
// Five of six is not "mostly there". Without coordinates Navigate does nothing;
|
||||
// without an address the rider has nothing to read when they arrive. Only the
|
||||
// name and the city id were ever validated here, which is how a live base ended
|
||||
// up in the network with no address and no pin on the map.
|
||||
//
|
||||
// Lives in lib/ rather than inside the page so it can be tested as the pure
|
||||
// decision it is, in the same shape as orderFlow and batchBucket.
|
||||
|
||||
/** A coordinate that is present, numeric, in range, and not the 0,0 that means
|
||||
* "never pinned". 0,0 is in the Gulf of Guinea; no Doormile base is there, so
|
||||
* it is always an unset value rather than a location. */
|
||||
const coordinateError = (value, { min, max, axis }) => {
|
||||
if (value === '' || value === null || value === undefined) return 'Navigate cannot work without a pin';
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) return 'Enter a number';
|
||||
if (n === 0) return 'Navigate cannot work without a pin';
|
||||
if (n < min || n > max) return `${axis} must be between ${min} and ${max}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the hub create/edit form.
|
||||
*
|
||||
* Returns `{ errors, values }`. `errors` is keyed by field name and is empty
|
||||
* when the form is good; `values` carries the coordinates already coerced to
|
||||
* numbers, so the caller does not re-parse what was just validated.
|
||||
*/
|
||||
export const validateHubForm = (form = {}) => {
|
||||
const errors = {};
|
||||
|
||||
if (!String(form.hubname || '').trim()) errors.hubname = 'Give the hub a name';
|
||||
|
||||
if (form.applocationid === '' || form.applocationid === null || form.applocationid === undefined
|
||||
|| Number.isNaN(Number(form.applocationid))) {
|
||||
errors.applocationid = 'Enter the numeric city id';
|
||||
}
|
||||
|
||||
if (!String(form.address || '').trim()) errors.address = 'A rider needs an address to find the gate';
|
||||
if (!String(form.pincode || '').trim()) errors.pincode = 'Needed to tell two bases in one city apart';
|
||||
|
||||
const latError = coordinateError(form.latitude, { min: -90, max: 90, axis: 'Latitude' });
|
||||
if (latError) errors.latitude = latError;
|
||||
const lngError = coordinateError(form.longitude, { min: -180, max: 180, axis: 'Longitude' });
|
||||
if (lngError) errors.longitude = lngError;
|
||||
|
||||
return {
|
||||
errors,
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
values: {
|
||||
latitude: Number(form.latitude),
|
||||
longitude: Number(form.longitude),
|
||||
applocationid: Number(form.applocationid)
|
||||
}
|
||||
};
|
||||
};
|
||||
567
src/lib/orderFlow.js
Normal file
567
src/lib/orderFlow.js
Normal file
@@ -0,0 +1,567 @@
|
||||
// ==============================|| Order flow classification ||============================== //
|
||||
//
|
||||
// A booking is two coordinate pairs and nothing else — `pickup*` and
|
||||
// `delivery*`. The backend has never carried a field saying which END is a
|
||||
// Doormile facility, so every consumer in the console silently assumed the
|
||||
// pickup was always the hub. That assumption is baked into three places that
|
||||
// break the moment it stops holding:
|
||||
//
|
||||
// • queries.js used the TENANT NAME as the pickup point's identity, so every
|
||||
// order of a tenant claimed the same collection point wherever it actually
|
||||
// was.
|
||||
// • Dispatch's kitchen map grouped by that name and took its pin coordinates
|
||||
// from whichever order happened to be first in the array.
|
||||
// • buildTripPoints drew a rider's polyline as [one pickup, drop, drop, …].
|
||||
//
|
||||
// This module is the single answer to "which end is the facility?", so those
|
||||
// consumers agree with each other by construction rather than by three
|
||||
// independent guesses.
|
||||
//
|
||||
// TWO SOURCES, in priority order:
|
||||
//
|
||||
// 1. An EXPLICIT `ordertype` on the booking. The console now sends this on
|
||||
// create (see buildFlowFields below). Once the backend persists and
|
||||
// returns it, it is authoritative and no geometry is consulted.
|
||||
// 2. DERIVATION by proximity to a known facility. This is what makes the
|
||||
// whole existing order history classify correctly today, with no backend
|
||||
// change and no migration — an order created before this module existed
|
||||
// still lands in the right bucket because its pickup coordinates still
|
||||
// sit on top of the hub they were copied from.
|
||||
//
|
||||
// When neither resolves — no anchors loaded, or coordinates missing — the
|
||||
// answer is FORWARD. That is deliberately the pre-existing behaviour: an
|
||||
// unclassifiable order must look exactly like it did before this file was
|
||||
// added, never like a new flow the operator did not create.
|
||||
|
||||
/** Metres. A hub pickup copies the facility's own coordinates onto the
|
||||
* booking, so the true distance is 0; the radius absorbs the operator who
|
||||
* re-pinned the gate instead of the unit, and a geocoder that rounds. Wide
|
||||
* enough for a compound, tight enough that the customer across the road is
|
||||
* still a customer. */
|
||||
export const ANCHOR_RADIUS_M = 200;
|
||||
|
||||
export const PICKUP_SOURCE = Object.freeze({ HUB: 'hub', CUSTOMER: 'customer' });
|
||||
export const DROP_TARGET = Object.freeze({ HUB: 'hub', CUSTOMER: 'customer' });
|
||||
|
||||
/**
|
||||
* The backend's `pickup_source_type` vocabulary — what KIND of place a parcel
|
||||
* is collected from. Four values where this module has two, and the difference
|
||||
* is load-bearing rather than cosmetic.
|
||||
*
|
||||
* `PICKUP_SOURCE` above is a geometry question: is the collection point a
|
||||
* facility, or a doorstep? A client's own kitchen answers HUB to that, because
|
||||
* it is a facility with coordinates on file. But to a rider it is a merchant,
|
||||
* not a base — different signage, different person to ask for, different screen
|
||||
* heading. Collapsing the two is exactly what left the rider app titling every
|
||||
* logistics pickup with the rider's own base name, so a collection at a shop
|
||||
* and one at a house looked identical.
|
||||
*
|
||||
* So: the anchor's `kind` decides this, not `pickupSource`.
|
||||
*
|
||||
* These strings are the wire contract and must match the backend's constants
|
||||
* exactly. The rider app renders `hub` as "Base"; that is its wording, not ours,
|
||||
* and it never travels back up the wire.
|
||||
*/
|
||||
export const PICKUP_SOURCE_TYPE = Object.freeze({
|
||||
HUB: 'hub',
|
||||
CUSTOMER: 'customer',
|
||||
MERCHANT: 'merchant',
|
||||
STORE: 'store'
|
||||
});
|
||||
|
||||
const PICKUP_SOURCE_TYPE_VALUES = Object.values(PICKUP_SOURCE_TYPE);
|
||||
|
||||
/** Operator-facing wording for a source type. */
|
||||
export const PICKUP_SOURCE_TYPE_LABEL = Object.freeze({
|
||||
[PICKUP_SOURCE_TYPE.HUB]: 'Base',
|
||||
[PICKUP_SOURCE_TYPE.CUSTOMER]: 'Customer door',
|
||||
[PICKUP_SOURCE_TYPE.MERCHANT]: 'Client site',
|
||||
[PICKUP_SOURCE_TYPE.STORE]: 'Store'
|
||||
});
|
||||
|
||||
/** An unrecognised type is shown as itself rather than dropped — the backend
|
||||
* may add values, and a blank chip is worse than an unfamiliar word. */
|
||||
export const pickupSourceTypeLabel = (type) =>
|
||||
PICKUP_SOURCE_TYPE_LABEL[type] || type || PICKUP_SOURCE_TYPE_LABEL[PICKUP_SOURCE_TYPE.CUSTOMER];
|
||||
|
||||
const isKnownSourceType = (value) => PICKUP_SOURCE_TYPE_VALUES.includes(value);
|
||||
|
||||
/**
|
||||
* Which of the four the matched pickup anchor is. No anchor means the rider is
|
||||
* going to somebody's door, and `customer` is the honest answer for that — a
|
||||
* value, never an omission, because "no id because it is a front door" has to be
|
||||
* distinguishable from "no id because nobody filled it in".
|
||||
*/
|
||||
export const sourceTypeForAnchor = (anchor) => {
|
||||
if (!anchor) return PICKUP_SOURCE_TYPE.CUSTOMER;
|
||||
if (anchor.kind === 'hub') return PICKUP_SOURCE_TYPE.HUB;
|
||||
if (anchor.kind === 'tenantlocation') return PICKUP_SOURCE_TYPE.MERCHANT;
|
||||
return PICKUP_SOURCE_TYPE.CUSTOMER;
|
||||
};
|
||||
|
||||
export const FLOW = Object.freeze({
|
||||
/** hub → customer. The classic outbound run, and everything the console did
|
||||
* before this module. */
|
||||
FORWARD: 'Forward',
|
||||
/** customer → hub. "Customer pickup": the rider travels out to the
|
||||
* customer's own door, collects there, and brings the parcel inbound.
|
||||
* Returns, merchant collections, RTO. */
|
||||
REVERSE: 'Reverse',
|
||||
/** customer → customer. Hyperlocal point-to-point; no facility is a physical
|
||||
* stop on the trip, the hub only OWNS the order for zone and reporting. */
|
||||
P2P: 'P2P',
|
||||
/** hub → hub. Trunk movement between facilities. Tripsheets already model
|
||||
* this properly; it is named here so a booking that happens to look like it
|
||||
* is not silently mislabelled as something else. */
|
||||
LINEHAUL: 'Linehaul'
|
||||
});
|
||||
|
||||
const FLOW_BY_ENDPOINTS = {
|
||||
[`${PICKUP_SOURCE.HUB}|${DROP_TARGET.CUSTOMER}`]: FLOW.FORWARD,
|
||||
[`${PICKUP_SOURCE.CUSTOMER}|${DROP_TARGET.HUB}`]: FLOW.REVERSE,
|
||||
[`${PICKUP_SOURCE.CUSTOMER}|${DROP_TARGET.CUSTOMER}`]: FLOW.P2P,
|
||||
[`${PICKUP_SOURCE.HUB}|${DROP_TARGET.HUB}`]: FLOW.LINEHAUL
|
||||
};
|
||||
|
||||
const ENDPOINTS_BY_FLOW = {
|
||||
[FLOW.FORWARD]: { pickupSource: PICKUP_SOURCE.HUB, dropTarget: DROP_TARGET.CUSTOMER },
|
||||
[FLOW.REVERSE]: { pickupSource: PICKUP_SOURCE.CUSTOMER, dropTarget: DROP_TARGET.HUB },
|
||||
[FLOW.P2P]: { pickupSource: PICKUP_SOURCE.CUSTOMER, dropTarget: DROP_TARGET.CUSTOMER },
|
||||
[FLOW.LINEHAUL]: { pickupSource: PICKUP_SOURCE.HUB, dropTarget: DROP_TARGET.HUB }
|
||||
};
|
||||
|
||||
/** Operator-facing wording. `Reverse` is the internal name; "Customer Pickup"
|
||||
* is what the people running the board actually call it, so that is what the
|
||||
* chip says. */
|
||||
export const FLOW_LABEL = Object.freeze({
|
||||
[FLOW.FORWARD]: 'Hub Pickup',
|
||||
[FLOW.REVERSE]: 'Customer Pickup',
|
||||
[FLOW.P2P]: 'Point to Point',
|
||||
[FLOW.LINEHAUL]: 'Linehaul'
|
||||
});
|
||||
|
||||
export const flowLabel = (flow) => FLOW_LABEL[flow] || FLOW_LABEL[FLOW.FORWARD];
|
||||
|
||||
/** True when the rider must travel out to a non-facility address to collect —
|
||||
* i.e. the trip carries a first-mile leg that a hub run does not. Both the
|
||||
* route builder and the deadhead pricing key off this, not off the flow name,
|
||||
* so adding a fifth flow later cannot leave one of them behind. */
|
||||
export const hasCustomerPickup = (flow) =>
|
||||
ENDPOINTS_BY_FLOW[flow]?.pickupSource === PICKUP_SOURCE.CUSTOMER;
|
||||
|
||||
/** True when the parcel ends at a facility rather than a doorstep. */
|
||||
export const hasHubDrop = (flow) => ENDPOINTS_BY_FLOW[flow]?.dropTarget === DROP_TARGET.HUB;
|
||||
|
||||
const isValidFlow = (value) => Object.values(FLOW).includes(value);
|
||||
|
||||
/** Accepts the loose casing a backend or a spreadsheet may send
|
||||
* ("reverse", "REVERSE", "customer_pickup") and returns a canonical FLOW, or
|
||||
* null when the value means nothing to us. Never throws — an unrecognised
|
||||
* string must fall through to derivation, not blow up a page render. */
|
||||
export const normalizeFlow = (value) => {
|
||||
const raw = String(value ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
||||
if (!raw) return null;
|
||||
const direct = Object.values(FLOW).find((f) => f.toLowerCase() === raw);
|
||||
if (direct) return direct;
|
||||
if (raw === 'customer_pickup' || raw === 'reverse_pickup' || raw === 'pickup') return FLOW.REVERSE;
|
||||
if (raw === 'hub_pickup' || raw === 'delivery' || raw === 'outbound') return FLOW.FORWARD;
|
||||
if (raw === 'point_to_point' || raw === 'hyperlocal' || raw === 'p2p') return FLOW.P2P;
|
||||
if (raw === 'line_haul' || raw === 'trunk') return FLOW.LINEHAUL;
|
||||
return null;
|
||||
};
|
||||
|
||||
const toNum = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
};
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
|
||||
const distanceMetres = (lat1, lon1, lat2, lon2) => {
|
||||
const a1 = toNum(lat1);
|
||||
const o1 = toNum(lon1);
|
||||
const a2 = toNum(lat2);
|
||||
const o2 = toNum(lon2);
|
||||
if ([a1, o1, a2, o2].some(Number.isNaN)) return Infinity;
|
||||
// A 0,0 coordinate is the backend's "unset", not the Gulf of Guinea. Treating
|
||||
// it as a real point would put every unset pickup ~7000 km from every hub,
|
||||
// which is harmless for the radius test but reads as a real measurement to
|
||||
// anything that later logs this distance.
|
||||
if ((a1 === 0 && o1 === 0) || (a2 === 0 && o2 === 0)) return Infinity;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(a2 - a1);
|
||||
const dLon = toRad(o2 - o1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a1)) * Math.cos(toRad(a2)) * Math.sin(dLon / 2) ** 2;
|
||||
return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||
};
|
||||
|
||||
export const metresBetween = distanceMetres;
|
||||
|
||||
/** Decimal places used to bucket a doorstep collection into a grouping key.
|
||||
* Four is ~11 m at this latitude — tight enough that two neighbouring houses
|
||||
* stay separate, loose enough that one address geocoded twice lands in one
|
||||
* bucket. Two collections at the SAME door must share a key: a real sheet
|
||||
* (three home kitchens, thirteen drops) has six rows leaving one kitchen, and
|
||||
* keying those per-booking would draw six pins stacked on one another. */
|
||||
const COLLECTION_POINT_PRECISION = 4;
|
||||
|
||||
/** Can this coordinate pair be reasoned about at all? Missing, non-numeric and
|
||||
* the backend's 0,0 "unset" all answer no. An endpoint that answers no can
|
||||
* never be *proved* to be a customer's door — the absence of a hub match
|
||||
* there means nothing — so classification must not read anything into it. */
|
||||
const isUsablePoint = (lat, lon) => {
|
||||
const a = toNum(lat);
|
||||
const o = toNum(lon);
|
||||
if (Number.isNaN(a) || Number.isNaN(o)) return false;
|
||||
return !(a === 0 && o === 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fold the two facility lists the console knows about into one anchor set.
|
||||
*
|
||||
* `hubs` are Doormile's own sorting centres and delivery hubs (GET
|
||||
* /admin/hubs). `tenantLocations` are a client's own origins — the kitchen,
|
||||
* the depot, the branch (GET /admin/tenants/:id/locations). Both count as "a
|
||||
* facility, not a customer's door", and an order picked up at either one is a
|
||||
* hub pickup as far as the board is concerned.
|
||||
*
|
||||
* Anchors without usable coordinates are dropped rather than kept with NaN:
|
||||
* a hub nobody geocoded cannot confirm OR deny anything, and keeping it would
|
||||
* make every distance comparison against it Infinity anyway.
|
||||
*/
|
||||
/** How far a facility can sit from a hub and still be served by it. 35 km is
|
||||
* not arbitrary — it is the radius ZoneContext already uses to decide whether
|
||||
* an order belongs to a hub's zone. Deriving a different number here would let
|
||||
* an order be stamped with one hub and then filtered into another. */
|
||||
export const SERVICING_HUB_RADIUS_M = 35000;
|
||||
|
||||
/** The hub that services a point, or null. Nearest wins, for the same reason
|
||||
* matchAnchor picks nearest: array order is not a fact about geography. */
|
||||
const nearestHubId = (lat, lon, hubAnchors) => {
|
||||
let bestId = null;
|
||||
let bestDist = Infinity;
|
||||
(hubAnchors || []).forEach((h) => {
|
||||
if (h.hubid == null) return;
|
||||
const d = distanceMetres(lat, lon, h.lat, h.lon);
|
||||
if (d <= SERVICING_HUB_RADIUS_M && d < bestDist) {
|
||||
bestId = h.hubid;
|
||||
bestDist = d;
|
||||
}
|
||||
});
|
||||
return bestId;
|
||||
};
|
||||
|
||||
export const buildAnchors = ({ hubs = [], tenantLocations = [] } = {}) => {
|
||||
const anchors = [];
|
||||
const hubAnchors = [];
|
||||
|
||||
(hubs || []).forEach((h) => {
|
||||
const lat = toNum(h?.latitude ?? h?.lat);
|
||||
const lon = toNum(h?.longitude ?? h?.lng ?? h?.lon);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lon)) return;
|
||||
const id = h?.hubid ?? h?.id;
|
||||
const anchor = {
|
||||
key: `hub:${id}`,
|
||||
hubid: id ?? null,
|
||||
tenantid: null,
|
||||
name: h?.hubname || h?.name || h?.address || `Hub #${id}`,
|
||||
address: h?.address || '',
|
||||
lat,
|
||||
lon,
|
||||
kind: 'hub'
|
||||
};
|
||||
anchors.push(anchor);
|
||||
hubAnchors.push(anchor);
|
||||
});
|
||||
|
||||
(tenantLocations || []).forEach((l) => {
|
||||
const lat = toNum(l?.latitude ?? l?.lat);
|
||||
const lon = toNum(l?.longitude ?? l?.lng ?? l?.lon);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lon)) return;
|
||||
const id = l?.tenantlocationid ?? l?.locationid ?? l?.id;
|
||||
anchors.push({
|
||||
key: `loc:${id}`,
|
||||
// A tenant location record carries NO hubid — the API simply has no such
|
||||
// field on it. Left as null, every order originating at a client's own
|
||||
// kitchen went to the backend with no servicing hub at all, which is the
|
||||
// whole reason zone attribution still had to fall back to matching hub
|
||||
// names against concatenated address text. Since a location is physically
|
||||
// served by whichever hub covers its city, the nearest one within
|
||||
// SERVICING_HUB_RADIUS_M is that answer.
|
||||
hubid: l?.hubid ?? nearestHubId(lat, lon, hubAnchors),
|
||||
tenantid: l?.tenantid ?? null,
|
||||
name: l?.locationname || l?.name || l?.address || `Location #${id}`,
|
||||
address: l?.address || '',
|
||||
lat,
|
||||
lon,
|
||||
kind: 'tenantlocation'
|
||||
});
|
||||
});
|
||||
|
||||
return anchors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nearest anchor within ANCHOR_RADIUS_M, or null.
|
||||
*
|
||||
* Nearest rather than first-within-radius: two facilities can share a compound
|
||||
* (a sorting centre and a client depot at the same address is normal), and
|
||||
* picking whichever was earlier in the array would name the pin inconsistently
|
||||
* depending on the order /admin/hubs happened to return.
|
||||
*/
|
||||
export const matchAnchor = (lat, lon, anchors = [], radiusM = ANCHOR_RADIUS_M) => {
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
(anchors || []).forEach((a) => {
|
||||
const d = distanceMetres(lat, lon, a.lat, a.lon);
|
||||
if (d <= radiusM && d < bestDist) {
|
||||
best = a;
|
||||
bestDist = d;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
};
|
||||
|
||||
const pickupCoords = (b) => ({
|
||||
lat: b?.pickuplatitude ?? b?.pickuplat ?? b?.pickup_lat,
|
||||
lon: b?.pickuplongitude ?? b?.pickuplong ?? b?.pickup_lon
|
||||
});
|
||||
|
||||
const dropCoords = (b) => ({
|
||||
lat: b?.deliverylatitude ?? b?.droplat ?? b?.deliverylat ?? b?.delivery_lat,
|
||||
lon: b?.deliverylongitude ?? b?.droplon ?? b?.deliverylong ?? b?.delivery_lon
|
||||
});
|
||||
|
||||
/**
|
||||
* Classify one booking.
|
||||
*
|
||||
* Returns, always, a complete shape — callers render from it directly and must
|
||||
* never have to null-check a field into a blank badge.
|
||||
*
|
||||
* flow one of FLOW
|
||||
* pickupSource PICKUP_SOURCE — which kind of place the parcel leaves
|
||||
* dropTarget DROP_TARGET — which kind of place it arrives at
|
||||
* originAnchor the matched facility at the pickup end, or null
|
||||
* destAnchor the matched facility at the drop end, or null
|
||||
* pickupPointKey STABLE grouping key for the collection point. This is the
|
||||
* field Dispatch groups its map pins by. For a facility it
|
||||
* is the anchor key, so every order off that hub shares one
|
||||
* pin; for a customer pickup it is per-booking, because two
|
||||
* customer doors are two different places and merging them
|
||||
* is exactly the bug this replaces.
|
||||
* pickupPointName what to show for that point
|
||||
* servicingHubId the hub that OWNS the order for zone filtering and
|
||||
* reporting — deliberately NOT "the hub we pick up from",
|
||||
* because on a P2P run there is no such hub and the order
|
||||
* still belongs to a zone.
|
||||
* derived true when geometry decided it, false when the booking
|
||||
* carried an explicit ordertype. Useful when an operator
|
||||
* asks why a row is labelled the way it is.
|
||||
*/
|
||||
export const classifyBooking = (booking, anchors = []) => {
|
||||
const b = booking || {};
|
||||
const p = pickupCoords(b);
|
||||
const d = dropCoords(b);
|
||||
|
||||
const originAnchor = matchAnchor(p.lat, p.lon, anchors);
|
||||
const destAnchor = matchAnchor(d.lat, d.lon, anchors);
|
||||
|
||||
const explicit = normalizeFlow(b.ordertype ?? b.order_type ?? b.flowtype ?? b.flow_type);
|
||||
|
||||
// The backend now stores what kind of place a booking is collected from, and
|
||||
// returns it on the row. When it is there it is the answer — it was recorded
|
||||
// at creation by whoever actually knew, rather than re-inferred from two
|
||||
// coordinates every time a page loads. Geometry stays as the classifier for
|
||||
// the whole order history created before the column existed.
|
||||
const storedSourceType = isKnownSourceType(b.pickup_source_type ?? b.pickupsourcetype)
|
||||
? b.pickup_source_type ?? b.pickupsourcetype
|
||||
: null;
|
||||
const storedPickupHubId = b.pickuphubid ?? b.pickup_hub_id ?? null;
|
||||
|
||||
let flow;
|
||||
let pickupSource;
|
||||
let dropTarget;
|
||||
|
||||
if (explicit) {
|
||||
flow = explicit;
|
||||
({ pickupSource, dropTarget } = ENDPOINTS_BY_FLOW[explicit]);
|
||||
} else if (!anchors?.length || !isUsablePoint(p.lat, p.lon)) {
|
||||
// Either no facility list loaded, or the booking has no usable pickup
|
||||
// coordinates. Geometry can prove nothing in either case, so hold the
|
||||
// pre-existing behaviour rather than inventing a flow from silence — a
|
||||
// pickup that failed to geocode is a data problem, not a customer pickup,
|
||||
// and labelling it as one would put a phantom collection pin on the map.
|
||||
flow = FLOW.FORWARD;
|
||||
({ pickupSource, dropTarget } = ENDPOINTS_BY_FLOW[FLOW.FORWARD]);
|
||||
} else {
|
||||
pickupSource = originAnchor ? PICKUP_SOURCE.HUB : PICKUP_SOURCE.CUSTOMER;
|
||||
// Same reasoning at the drop end: an un-geocoded drop cannot be shown to
|
||||
// be a facility, and the overwhelmingly common case is a customer.
|
||||
dropTarget = destAnchor && isUsablePoint(d.lat, d.lon) ? DROP_TARGET.HUB : DROP_TARGET.CUSTOMER;
|
||||
flow = FLOW_BY_ENDPOINTS[`${pickupSource}|${dropTarget}`];
|
||||
}
|
||||
|
||||
const bookingKey = b.bookingid ?? b.id ?? b.orderheaderid ?? b.bookingno ?? 'unknown';
|
||||
|
||||
// Stored value wins; otherwise the matched anchor's kind decides, which is the
|
||||
// same judgement the create form makes when it stamps the field.
|
||||
const pickupSourceType = storedSourceType ?? sourceTypeForAnchor(originAnchor);
|
||||
|
||||
const isHubPickup = pickupSource === PICKUP_SOURCE.HUB;
|
||||
|
||||
// A facility groups by its anchor. A doorstep groups by WHERE IT IS, so two
|
||||
// bookings collected at one door are one visit and one pin, while two
|
||||
// different doors stay apart. The booking id is only the last resort, for a
|
||||
// collection whose coordinates never resolved — those must not merge with
|
||||
// each other, because nothing has shown them to be the same place.
|
||||
const pickupPointKey =
|
||||
isHubPickup && originAnchor
|
||||
? originAnchor.key
|
||||
: isUsablePoint(p.lat, p.lon)
|
||||
? `cust:${Number(p.lat).toFixed(COLLECTION_POINT_PRECISION)},${Number(p.lon).toFixed(
|
||||
COLLECTION_POINT_PRECISION
|
||||
)}`
|
||||
: `cust:${bookingKey}`;
|
||||
|
||||
const pickupPointName = isHubPickup
|
||||
? originAnchor?.name || b.pickupaddress || 'Hub'
|
||||
: b.pickupcontactname || b.pickup_name || b.pickupaddress || 'Customer Pickup';
|
||||
|
||||
// Explicit ids first, matched anchor second, destination facility third. The
|
||||
// last one matters for a customer pickup that is inbound to a hub: nothing
|
||||
// on the pickup end names a facility, but the order plainly belongs to the
|
||||
// hub it is being brought to.
|
||||
const servicingHubId =
|
||||
b.sourcehubid ??
|
||||
b.hubid ??
|
||||
(originAnchor?.kind === 'hub' ? originAnchor.hubid : null) ??
|
||||
originAnchor?.hubid ??
|
||||
(destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ??
|
||||
destAnchor?.hubid ??
|
||||
null;
|
||||
|
||||
return {
|
||||
flow,
|
||||
flowLabel: flowLabel(flow),
|
||||
pickupSource,
|
||||
/** One of PICKUP_SOURCE_TYPE — the backend's four-value vocabulary, not the
|
||||
* facility/doorstep binary above. */
|
||||
pickupSourceType,
|
||||
pickupSourceTypeLabel: pickupSourceTypeLabel(pickupSourceType),
|
||||
/** The base a Base -> Customer booking is collected FROM, when the row
|
||||
* names one. Null for every other kind of pickup. */
|
||||
pickupHubId:
|
||||
storedPickupHubId ??
|
||||
(pickupSourceType === PICKUP_SOURCE_TYPE.HUB ? originAnchor?.hubid ?? null : null),
|
||||
/** True when the type came off the row rather than out of geometry. */
|
||||
sourceTypeFromServer: storedSourceType != null,
|
||||
dropTarget,
|
||||
originAnchor,
|
||||
destAnchor,
|
||||
pickupPointKey,
|
||||
pickupPointName,
|
||||
servicingHubId,
|
||||
derived: !explicit
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The flow fields to merge into a create-booking payload.
|
||||
*
|
||||
* Sent on every create even though the backend may not store them yet: an
|
||||
* unknown key is ignored server-side, and the day it IS stored, every order
|
||||
* created from that point carries its type explicitly instead of relying on
|
||||
* the console re-deriving it from coordinates on each page load.
|
||||
*
|
||||
* `sourcehubid`/`hubid` are the pair that fixes zone attribution. The console
|
||||
* has always READ `b.hubid`/`b.sourcehubid` off a booking (queries.js) and
|
||||
* never once written them, which is why ZoneContext had to fall back to
|
||||
* matching hub names against concatenated address text.
|
||||
*/
|
||||
export const buildFlowFields = ({
|
||||
flow,
|
||||
servicingHubId,
|
||||
tenantLocationId,
|
||||
pickupAnchor = null,
|
||||
pickupSourceType
|
||||
} = {}) => {
|
||||
const resolved = isValidFlow(flow) ? flow : FLOW.FORWARD;
|
||||
const fields = { ordertype: resolved };
|
||||
if (servicingHubId != null && servicingHubId !== '') {
|
||||
fields.sourcehubid = Number(servicingHubId) || servicingHubId;
|
||||
fields.hubid = fields.sourcehubid;
|
||||
}
|
||||
if (tenantLocationId != null && tenantLocationId !== '') {
|
||||
fields.tenantlocationid = Number(tenantLocationId) || tenantLocationId;
|
||||
}
|
||||
|
||||
// pickup_source_type is stored and returned by the backend, and the rider app
|
||||
// reads it to title the stop. It is sent on EVERY create, "customer"
|
||||
// included — an omission would be read as "nobody filled this in", which is a
|
||||
// different fact from "this is somebody's front door".
|
||||
//
|
||||
// A caller-supplied type wins only if it is one of the known values; anything
|
||||
// else falls back to what the matched anchor says, so a typo cannot put a word
|
||||
// on the wire that no reader has a meaning for.
|
||||
const resolvedType = isKnownSourceType(pickupSourceType)
|
||||
? pickupSourceType
|
||||
: sourceTypeForAnchor(pickupAnchor);
|
||||
fields.pickup_source_type = resolvedType;
|
||||
|
||||
// Only a base pickup names a base. Sending pickuphubid alongside a merchant
|
||||
// or customer type would tell the backend this is a Base -> Customer run when
|
||||
// it is not.
|
||||
if (resolvedType === PICKUP_SOURCE_TYPE.HUB) {
|
||||
const baseId = pickupAnchor?.kind === 'hub' ? pickupAnchor.hubid : null;
|
||||
if (baseId != null && baseId !== '') {
|
||||
fields.pickuphubid = Number(baseId) || baseId;
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the flow from what the CREATE form knows, before a booking exists.
|
||||
*
|
||||
* BOTH ends are measured, and geometry outranks the operator's toggle.
|
||||
*
|
||||
* That is not pedantry — it is what keeps the write path and the read path
|
||||
* telling the same story. Whatever this stamps on the payload, classifyBooking
|
||||
* will later re-derive from the very same coordinates when the board loads the
|
||||
* booking back. If the two used different rules the same order would read "Point
|
||||
* to Point" in one place and "Hub Pickup" in another, which is precisely the
|
||||
* kind of disagreement this module exists to end.
|
||||
*
|
||||
* A real case makes it concrete: a tenant's bulk file collects from three home
|
||||
* kitchens, one of which IS their registered business location. Those rows are
|
||||
* hub pickups however the operator set the toggle, because that is what the
|
||||
* coordinates say — and because the board will say so regardless.
|
||||
*
|
||||
* The toggle still decides when geometry cannot: a pickup with no usable
|
||||
* coordinates falls back to what the operator declared.
|
||||
*/
|
||||
export const flowForDraft = ({
|
||||
pickupSource,
|
||||
pickupLat,
|
||||
pickupLng,
|
||||
dropLat,
|
||||
dropLng,
|
||||
anchors = []
|
||||
} = {}) => {
|
||||
const originAnchor = matchAnchor(pickupLat, pickupLng, anchors);
|
||||
const destAnchor = matchAnchor(dropLat, dropLng, anchors);
|
||||
|
||||
const source = isUsablePoint(pickupLat, pickupLng)
|
||||
? originAnchor
|
||||
? PICKUP_SOURCE.HUB
|
||||
: PICKUP_SOURCE.CUSTOMER
|
||||
: pickupSource === PICKUP_SOURCE.CUSTOMER
|
||||
? PICKUP_SOURCE.CUSTOMER
|
||||
: PICKUP_SOURCE.HUB;
|
||||
|
||||
const target = destAnchor && isUsablePoint(dropLat, dropLng) ? DROP_TARGET.HUB : DROP_TARGET.CUSTOMER;
|
||||
|
||||
return { flow: FLOW_BY_ENDPOINTS[`${source}|${target}`], originAnchor, destAnchor };
|
||||
};
|
||||
78
src/lib/routingSummary.js
Normal file
78
src/lib/routingSummary.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// ==============================|| Routing decision, in words ||============================== //
|
||||
//
|
||||
// `GET /admin/bookings/:id` returns a `routing` block: the decision the backend
|
||||
// took about where a parcel goes, and the inputs it took it from. This turns
|
||||
// that into the sentences the order drawer shows.
|
||||
//
|
||||
// It exists so support can answer "why does the rider's screen say hand over at
|
||||
// a base instead of deliver to the customer?" as a lookup instead of
|
||||
// reconstructing a pincode rule by hand on the phone.
|
||||
//
|
||||
// TWO RULES THIS FILE EXISTS TO HOLD:
|
||||
//
|
||||
// 1. A PROJECTION IS NOT A DECISION. Before pickup, nothing has been decided —
|
||||
// `routing.decided` is false and what is shown is what WILL happen. Saying
|
||||
// "this went via a base" about a parcel nobody has collected yet is a
|
||||
// wrong answer delivered confidently, which is worse than no answer.
|
||||
//
|
||||
// 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens here, at the
|
||||
// edge, and never travels back up: every value sent to the backend keeps
|
||||
// its wire spelling.
|
||||
|
||||
/** What the rider does next with the parcel. Keys are the backend's wire
|
||||
* values; an unrecognised one is shown as itself rather than swallowed —
|
||||
* the backend may add actions, and a blank row is worse than a raw word. */
|
||||
export const NEXT_ACTION_LABEL = Object.freeze({
|
||||
pickup: 'Collect from the pickup point',
|
||||
inward_at_hub: 'Carry to a base and hand over',
|
||||
start_delivery: 'Start the delivery run',
|
||||
deliver: 'Deliver to the receiver',
|
||||
handed_to_hub: 'Handed over at the base',
|
||||
none: 'Nothing further for the rider'
|
||||
});
|
||||
|
||||
export const nextActionLabel = (action) => NEXT_ACTION_LABEL[action] || action || '—';
|
||||
|
||||
/** Why the parcel is going the way it is, in one sentence an operator can read
|
||||
* to a rider over the phone. */
|
||||
export const routingReason = (routing) => {
|
||||
if (!routing) return '';
|
||||
return routing.is_hyperlocal
|
||||
? 'Same postal area — the collecting rider carries it straight to the receiver.'
|
||||
: 'Different postal area — it goes through a base rather than direct to the receiver.';
|
||||
};
|
||||
|
||||
/** The from → to line, with the honesty caveat when nothing has been decided
|
||||
* yet. A booking not yet collected has no routing FACT, only a forecast. */
|
||||
export const routingRoute = (routing) => {
|
||||
if (!routing) return '';
|
||||
const from = routing.from_pincode || '—';
|
||||
const to = routing.destination_pincode || '—';
|
||||
const line = `${from} → ${to}`;
|
||||
return routing.decided ? line : `${line} · not yet collected, so this is what will happen, not what has`;
|
||||
};
|
||||
|
||||
/** The base a parcel is routed to, as one readable line. Null when no base is
|
||||
* involved — which is the correct answer for a hyperlocal parcel, not a gap. */
|
||||
export const baseLine = (base) => {
|
||||
if (!base || !base.name) return null;
|
||||
return base.pincode ? `${base.name} · ${base.pincode}` : base.name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the drawer renders, in one call, so the component holds no
|
||||
* decisions of its own.
|
||||
*/
|
||||
export const summariseRouting = (routing) => {
|
||||
if (!routing) return null;
|
||||
return {
|
||||
decided: Boolean(routing.decided),
|
||||
reason: routingReason(routing),
|
||||
route: routingRoute(routing),
|
||||
nextAction: nextActionLabel(routing.next_action),
|
||||
base: baseLine(routing.next_hub),
|
||||
baseAddress: routing.next_hub?.address || null,
|
||||
consignmentState: routing.consignment_state || '—',
|
||||
inwardedAt: routing.inwardedat || null
|
||||
};
|
||||
};
|
||||
@@ -7,5 +7,5 @@ import '@/index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
592
src/pages/doormile/bookings/Bookings.jsx
Normal file
592
src/pages/doormile/bookings/Bookings.jsx
Normal file
@@ -0,0 +1,592 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet,
|
||||
Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Alert, Button, DataTable, Drawer, EmptyState, Grid, KpiCard,
|
||||
Modal, PageHeader, Stack, StatusBadge, Surface, Tabs,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
import { drainBookings } from '@/lib/assistant/scan';
|
||||
import {
|
||||
useBooking, useAssignMilerToBooking, useRiderOptions,
|
||||
useCancelBooking, useBulkCancelBookings,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
|
||||
import {
|
||||
BOOKING_TABS, bookingsForTab, customerAppBookings,
|
||||
quotedPrice, serviceOptionOf, serviceType,
|
||||
} from '@/lib/customerAppBookings';
|
||||
import { currency, exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
||||
|
||||
/**
|
||||
* Bookings — customer app (doormile_cx) bookings management with tab separation
|
||||
* (Created, Rider Assigned, Delivered, Cancelled) and bulk rider assignment.
|
||||
*/
|
||||
|
||||
const EXPORT_COLUMNS = [
|
||||
{ key: 'bookingno', header: 'Booking' },
|
||||
{ key: 'status', header: 'Status' },
|
||||
{ key: 'pickupaddress', header: 'Pickup' },
|
||||
{ key: 'pickuppincode', header: 'Pickup pincode' },
|
||||
{ key: 'deliveryaddress', header: 'Drop' },
|
||||
{ key: 'deliverypincode', header: 'Drop pincode' },
|
||||
{ key: 'service', header: 'Service', value: (row) => serviceType(row) },
|
||||
{ key: 'price', header: 'Quoted', value: (row) => quotedPrice(row) },
|
||||
{ key: 'createdat', header: 'Booked' },
|
||||
];
|
||||
|
||||
/** One booking in full. Fetched on open rather than up front. */
|
||||
function BookingDrawer({ bookingId, onClose }) {
|
||||
const { data: booking, isLoading, refetch: refetchBooking } = useBooking(bookingId);
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
const assignMutation = useAssignMilerToBooking();
|
||||
const [selectedRiderId, setSelectedRiderId] = useState('');
|
||||
|
||||
const parcels = booking?.parcels || [];
|
||||
const payments = booking?.payments || [];
|
||||
const price = serviceOptionOf(booking);
|
||||
|
||||
const assignedUserId = booking?.assignedmileruserid || booking?.mileruserid;
|
||||
const assignedRider = useMemo(() => {
|
||||
if (!assignedUserId) return null;
|
||||
return riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(assignedUserId));
|
||||
}, [assignedUserId, riders]);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedRiderId || !bookingId) return;
|
||||
const target = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
|
||||
const targetUserId = target?.userid || target?.mileruserid || target?.id || selectedRiderId;
|
||||
assignMutation.mutate(
|
||||
{ id: bookingId, mileruserid: Number(targetUserId) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedRiderId('');
|
||||
refetchBooking();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const isAssignable = booking?.status !== 'Cancelled' && booking?.status !== 'Delivered';
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={Boolean(bookingId)}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={booking?.bookingno ? `Booking ${booking.bookingno}` : 'Booking'}
|
||||
icon={PackageSearch}
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-body-sm text-ink-3">Loading booking…</p>
|
||||
) : !booking ? (
|
||||
<p className="text-body-sm text-ink-3">This booking could not be loaded.</p>
|
||||
) : (
|
||||
<Stack space="default">
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
['Status', <StatusBadge status={booking.consignmentstatus || booking.status} size="sm" key="s" />],
|
||||
['Booked', formatDoormileTimestamp(booking.createdat)],
|
||||
['Collected from', pickupSourceTypeLabel(booking.pickup_source_type)],
|
||||
['Consignment', orDash(booking.consignmentid)],
|
||||
['Assigned Rider', assignedRider?.displayname || assignedRider?.name || (assignedUserId ? `Rider #${assignedUserId}` : 'Unassigned')],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
|
||||
<dd className="text-body-sm text-ink-1">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Surface>
|
||||
|
||||
{isAssignable && (
|
||||
<Surface variant="card" padding="sm" radius="lg">
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">
|
||||
{assignedUserId ? 'Reassign Rider' : 'Assign Rider'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select value={selectedRiderId} onValueChange={setSelectedRiderId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={assignedRider?.displayname || assignedRider?.name || 'Select rider…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{riders.map((r) => {
|
||||
const id = String(r.userid || r.mileruserid || r.id);
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
{r.label || r.displayname || r.name || `Rider #${id}`}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAssign}
|
||||
loading={assignMutation.isPending}
|
||||
disabled={!selectedRiderId || assignMutation.isPending}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
|
||||
<p className="text-body-sm text-ink-2">{orDash(booking.pickupaddress)}</p>
|
||||
<p className="text-caption text-ink-3">{orDash(booking.pickuppincode)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
|
||||
<p className="text-body-sm text-ink-2">{orDash(booking.deliveryaddress)}</p>
|
||||
<p className="text-caption text-ink-3">{orDash(booking.deliverypincode)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Price</p>
|
||||
{!price ? (
|
||||
<p className="text-body-sm text-ink-3">No price was quoted for this booking.</p>
|
||||
) : (
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
['Service', orDash(price.servicetype)],
|
||||
['Quoted', currency(price.estimatedprice)],
|
||||
['Expected delivery', formatDoormileTimestamp(price.estimateddeliveryat)],
|
||||
['SLA due', formatDoormileTimestamp(price.sladueat)],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
|
||||
<dd className="text-body-sm text-ink-1">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Surface>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">
|
||||
Parcels {parcels.length ? `· ${parcels.length}` : ''}
|
||||
</p>
|
||||
{parcels.length === 0 ? (
|
||||
<p className="text-body-sm text-ink-3">No parcel detail recorded.</p>
|
||||
) : (
|
||||
<Stack space="xs">
|
||||
{parcels.map((parcel, i) => (
|
||||
<Surface key={i} variant="subtle" padding="sm" radius="lg">
|
||||
<p className="text-body-sm font-medium text-ink-1">{orDash(parcel.itemcategory)}</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{orDash(parcel.itemdescription)}
|
||||
{parcel.weight ? ` · ${parcel.weight} kg` : ''}
|
||||
{parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
|
||||
</p>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payments.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Payments</p>
|
||||
<Stack space="xs">
|
||||
{payments.map((p, i) => (
|
||||
<Surface key={i} variant="subtle" padding="sm" radius="lg">
|
||||
<p className="text-body-sm text-ink-1">
|
||||
{currency(p.amount)} · {orDash(p.paymentmode)}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">{orDash(p.paymentstatus)}</p>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Bookings() {
|
||||
const [tab, setTab] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [openId, setOpenId] = useState(null);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [manualAssignOpen, setManualAssignOpen] = useState(false);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [selectedRiderId, setSelectedRiderId] = useState('');
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
|
||||
const { data: scan, isLoading, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: ['doormile', 'bookings', 'all'],
|
||||
queryFn: () => drainBookings({ cached: false }),
|
||||
refetchInterval: 15_000,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
const bookings = useMemo(() => customerAppBookings(scan?.rows), [scan]);
|
||||
|
||||
// Tab counts
|
||||
const counts = useMemo(() => {
|
||||
return {
|
||||
all: bookings.length,
|
||||
created: bookingsForTab(bookings, 'created').length,
|
||||
assigned: bookingsForTab(bookings, 'assigned').length,
|
||||
delivered: bookingsForTab(bookings, 'delivered').length,
|
||||
cancelled: bookingsForTab(bookings, 'cancelled').length,
|
||||
};
|
||||
}, [bookings]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
bookingsForTab(bookings, tab).filter((b) =>
|
||||
matchesQuery(b, ['bookingno', 'pickupaddress', 'deliveryaddress', 'status'], debouncedSearch)
|
||||
),
|
||||
[bookings, tab, debouncedSearch]
|
||||
);
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => rows.filter((row) => selectedIds.includes(row.bookingid)),
|
||||
[rows, selectedIds]
|
||||
);
|
||||
|
||||
// Clear selection when tab changes
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
}, [tab]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const created = counts.created;
|
||||
const value = bookings.reduce((sum, b) => sum + quotedPrice(b), 0);
|
||||
return { total: bookings.length, created, value };
|
||||
}, [bookings, counts]);
|
||||
|
||||
const riderLookup = useMemo(() => {
|
||||
const map = new Map();
|
||||
riders.forEach((r) => {
|
||||
const id = String(r.userid || r.mileruserid || r.id);
|
||||
map.set(id, r.displayname || r.name || r.label);
|
||||
});
|
||||
return map;
|
||||
}, [riders]);
|
||||
|
||||
// Bulk manual assign handler
|
||||
const handleBulkManualAssign = async () => {
|
||||
if (!selectedRiderId || !selectedIds.length) {
|
||||
OpenToast('Please select a rider first', 'warning');
|
||||
return;
|
||||
}
|
||||
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
|
||||
if (!targetRider) {
|
||||
OpenToast('Please select a valid rider', 'warning');
|
||||
return;
|
||||
}
|
||||
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
|
||||
setIsAssigning(true);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedIds.map((bookingId) =>
|
||||
assignMilerToBooking(bookingId, { mileruserid: Number(riderUserId) })
|
||||
)
|
||||
);
|
||||
|
||||
if (targetRider.milerprofileid) {
|
||||
notifyMiler(
|
||||
targetRider.milerprofileid,
|
||||
'DoormileXpress',
|
||||
`${selectedIds.length} customer booking(s) assigned to you`
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
OpenToast(
|
||||
`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||
refetch();
|
||||
setSelectedIds([]);
|
||||
setManualAssignOpen(false);
|
||||
setSelectedRiderId('');
|
||||
} catch (err) {
|
||||
OpenToast(messageOf(err, 'Failed to assign orders'), 'error', 4000);
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk cancel handler
|
||||
const handleBulkCancel = async () => {
|
||||
if (!selectedIds.length) return;
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const { bulkCancelBookings } = await import('@/api/doormile/endpoints');
|
||||
await bulkCancelBookings(selectedIds);
|
||||
OpenToast(`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} cancelled`, 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||
refetch();
|
||||
setSelectedIds([]);
|
||||
setCancelOpen(false);
|
||||
} catch (err) {
|
||||
OpenToast(messageOf(err, 'Failed to cancel orders'), 'error', 4000);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'booking',
|
||||
header: 'Booking',
|
||||
sortable: true,
|
||||
accessor: (row) => (
|
||||
<div>
|
||||
<p className="text-body-sm font-semibold text-ink-1">{orDash(row.bookingno)}</p>
|
||||
<p className="text-caption text-ink-3">{formatDoormileTimestamp(row.createdat)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'route',
|
||||
header: 'Route',
|
||||
accessor: (row) => (
|
||||
<div className="max-w-xs">
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||
{orDash(row.pickupaddress)}
|
||||
</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
→ {orDash(row.deliveryaddress)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rider',
|
||||
header: 'Rider',
|
||||
hideBelow: 'md',
|
||||
accessor: (row) => {
|
||||
const riderId = String(row.assignedmileruserid || row.mileruserid || '');
|
||||
const name = riderLookup.get(riderId);
|
||||
if (name) {
|
||||
return <span className="text-body-sm font-medium text-ink-1">{name}</span>;
|
||||
}
|
||||
if (riderId) {
|
||||
return <span className="text-caption text-ink-2">Rider #{riderId}</span>;
|
||||
}
|
||||
return <span className="text-caption text-ink-4">Unassigned</span>;
|
||||
},
|
||||
},
|
||||
{ key: 'service', header: 'Service', hideBelow: 'md', accessor: (row) => orDash(serviceType(row)) },
|
||||
{
|
||||
key: 'price',
|
||||
header: 'Price',
|
||||
sortable: true,
|
||||
accessor: (row) => (quotedPrice(row) ? currency(quotedPrice(row)) : '—'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
accessor: (row) => <StatusBadge status={row.consignmentstatus || row.status} size="sm" />,
|
||||
},
|
||||
],
|
||||
[riderLookup]
|
||||
);
|
||||
|
||||
const canAssign = tab === 'created' && selectedOrders.length > 0;
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
title="Bookings"
|
||||
subtitle="Booked by customers in the app — the counterpart to Orders, which this console creates."
|
||||
icon={BookMarked}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => refetch()} loading={isFetching}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isError && <Alert tone="danger">{messageOf(error, 'Could not load bookings')}</Alert>}
|
||||
|
||||
{scan?.truncated && (
|
||||
<Alert tone="warning">
|
||||
Showing the most recent {Number(scan.scanned || 0).toLocaleString('en-IN')} of{' '}
|
||||
{Number(scan.total || 0).toLocaleString('en-IN')} bookings. The counts below are a
|
||||
floor, not a total.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid cols={3} gap="default">
|
||||
<KpiCard label="Customer bookings" value={stats.total} />
|
||||
<KpiCard label="Created (Awaiting Rider)" value={stats.created} />
|
||||
<KpiCard label="Total quoted" value={currency(stats.value)} />
|
||||
</Grid>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
|
||||
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
|
||||
<Tabs
|
||||
tabs={BOOKING_TABS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
count: counts[t.value] ?? 0,
|
||||
}))}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search booking no, address or status"
|
||||
onExport={() => exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}`)}
|
||||
exportDisabled={rows.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.bookingid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
selectedIds={tab === 'created' ? selectedIds : undefined}
|
||||
onSelectionChange={tab === 'created' ? setSelectedIds : undefined}
|
||||
onRowClick={(row) => setOpenId(row.bookingid)}
|
||||
pageSize={25}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
debouncedSearch ? undefined : (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title={`No ${BOOKING_TABS.find((entry) => entry.value === tab)?.label.toLowerCase()} bookings`}
|
||||
description="Customer app bookings will appear here as they are placed."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Floating Bulk Action Bar */}
|
||||
{canAssign && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="fixed bottom-6 right-6 z-50 flex flex-wrap items-center gap-2.5 rounded-2xl border border-slate-200 bg-white/95 p-2.5 shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<div className="px-2 text-xs font-bold text-slate-700">
|
||||
{selectedOrders.length} selected
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-xs"
|
||||
onClick={() => setManualAssignOpen(true)}
|
||||
>
|
||||
<Bike className="mr-1.5 h-4 w-4" /> Assign Rider ({selectedOrders.length})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
<Ban className="mr-1.5 h-4 w-4" /> Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Bulk Assign Modal */}
|
||||
<Modal
|
||||
open={manualAssignOpen}
|
||||
onOpenChange={setManualAssignOpen}
|
||||
title={`Assign Rider to ${selectedIds.length} Booking${selectedIds.length === 1 ? '' : 's'}`}
|
||||
description="Choose an active rider to assign the selected customer bookings directly."
|
||||
>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
|
||||
Select Rider <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedRiderId}
|
||||
onChange={(e) => setSelectedRiderId(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-2xs focus:border-emerald-500 focus:outline-hidden focus:ring-2 focus:ring-emerald-500/20"
|
||||
>
|
||||
<option value="">-- Choose Rider --</option>
|
||||
{riders.map((r) => {
|
||||
const id = String(r.userid || r.mileruserid || r.id);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{r.displayname || r.name || `Rider #${id}`} {r.phone ? `(${r.phone})` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-slate-100">
|
||||
<Button variant="outline" onClick={() => setManualAssignOpen(false)} disabled={isAssigning}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={handleBulkManualAssign}
|
||||
loading={isAssigning}
|
||||
disabled={!selectedRiderId || isAssigning}
|
||||
>
|
||||
Confirm & Assign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Cancel Confirmation Modal */}
|
||||
<Modal
|
||||
open={cancelOpen}
|
||||
onOpenChange={setCancelOpen}
|
||||
title={`Cancel ${selectedIds.length} Customer Booking${selectedIds.length === 1 ? '' : 's'}`}
|
||||
description="Are you sure you want to cancel the selected bookings? This action cannot be undone."
|
||||
>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={isCancelling}>
|
||||
Go back
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleBulkCancel} loading={isCancelling}>
|
||||
Cancel Bookings
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<BookingDrawer bookingId={openId} onClose={() => setOpenId(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { useZone } from '@/lib/ZoneContext';
|
||||
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
|
||||
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
||||
import { summariseRouting } from '@/lib/routingSummary';
|
||||
|
||||
/**
|
||||
* Deliveries — orders that have moved past merely being created.
|
||||
@@ -640,6 +642,59 @@ export default function Deliveries() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this booking was routed, and on what.
|
||||
*
|
||||
* Backend decides the route; this only reports the decision and the inputs it
|
||||
* was made from, so support can answer "why does the rider's screen say hand
|
||||
* over at a base instead of deliver?" without reconstructing a pincode rule by
|
||||
* hand.
|
||||
*
|
||||
* Before pickup the decision has not been taken — `decided` is false and what is
|
||||
* shown is a projection from the captured from/to, labelled as such. A number
|
||||
* presented as a decision when it is only a forecast is worse than no number.
|
||||
*
|
||||
* Wording note: the wire says hub, operators and riders say base. The
|
||||
* translation happens here, at the edge, and never travels back up.
|
||||
*/
|
||||
function RoutingSummary({ routing }) {
|
||||
const s = summariseRouting(routing);
|
||||
if (!s) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Routing</p>
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<Stack space="xs">
|
||||
<p className="text-body-sm text-ink-1">{s.reason}</p>
|
||||
<p className="text-caption text-ink-3">{s.route}</p>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-3 pt-1">
|
||||
{[
|
||||
['Collected from', pickupSourceTypeLabel(routing.pickup_source_type)],
|
||||
['Parcel state', s.consignmentState],
|
||||
['Next step', s.nextAction],
|
||||
['Base', s.base || '—'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
|
||||
<dd className="text-body-sm text-ink-1">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{s.baseAddress ? <p className="text-caption text-ink-3">{s.baseAddress}</p> : null}
|
||||
{s.inwardedAt ? (
|
||||
<p className="text-caption text-ink-3">
|
||||
Received at the base {formatDoormileTimestamp(s.inwardedAt)}
|
||||
</p>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is actually in the parcel.
|
||||
*
|
||||
@@ -687,14 +742,40 @@ function OrderDetailDrawer({ row, onClose }) {
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
|
||||
<p className="text-body-sm text-ink-2">{orDash(row?.pickupaddress)}</p>
|
||||
<p className="text-body-sm text-ink-2">
|
||||
{orDash(booking?.routing?.from_address || row?.pickupaddress)}
|
||||
</p>
|
||||
{booking?.routing ? (
|
||||
<p className="text-caption text-ink-3">
|
||||
{[
|
||||
pickupSourceTypeLabel(booking.routing.pickup_source_type),
|
||||
booking.routing.pickup_source_name,
|
||||
booking.routing.from_pincode
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
|
||||
<p className="text-body-sm text-ink-2">{orDash(row?.deliveryaddress)}</p>
|
||||
<p className="text-body-sm text-ink-2">
|
||||
{orDash(booking?.routing?.to_address || row?.deliveryaddress)}
|
||||
</p>
|
||||
{booking?.routing?.destination_pincode ? (
|
||||
<p className="text-caption text-ink-3">{booking.routing.destination_pincode}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Why this parcel is going where it is going.
|
||||
A rider rings asking why his screen says hand over at a base
|
||||
instead of deliver to the customer; without this, answering meant
|
||||
reconstructing the routing rule from two pincodes by hand. The
|
||||
backend decides the route and reports it here with the inputs it
|
||||
used, so the answer is a lookup. */}
|
||||
{booking?.routing ? <RoutingSummary routing={booking.routing} /> : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">
|
||||
Parcels {parcels.length ? `· ${parcels.length}` : ''}
|
||||
|
||||
@@ -2886,6 +2886,30 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Flow chips. The class is built from the raw flow name
|
||||
(`type-${ordertype.toLowerCase()}`), so these four selectors track FLOW in
|
||||
lib/orderFlow.js. `type-forward` has no rule on purpose: an ordinary hub run
|
||||
never renders a chip at all (see flowChipLabel), and a rule for it would only
|
||||
fire if that ever changed. The colours match their map markers — violet is a
|
||||
doorstep collection in both places. */
|
||||
.dispatch-container .zone-order-chip.type-reverse {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.dispatch-container .zone-order-chip.type-p2p {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
border-color: rgba(14, 165, 233, 0.3);
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.dispatch-container .zone-order-chip.type-linehaul {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
border-color: rgba(100, 116, 139, 0.3);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dispatch-container .kitchen-transition {
|
||||
padding: 12px;
|
||||
background: var(--kitchen-soft);
|
||||
@@ -4620,6 +4644,16 @@
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 40px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
/* A doorstep collection is not a kitchen. Same shape and size — it is still a
|
||||
collection point and still groups a rider's stops — but violet rather than
|
||||
amber, so a dispatcher scanning the map can tell "load here, this is ours"
|
||||
from "go out and collect here" without opening the pin. The box-shadow is
|
||||
pinned inline by createKitchenIcon() (it carries the matching violet), which
|
||||
is why only the fill is set here. */
|
||||
.dispatch-container .kitchen-mark.is-customer-pickup {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
/* Focused kitchen marker — larger, brighter, with a pulsing halo so users
|
||||
never lose sight of the kitchen they drilled into. */
|
||||
.dispatch-container .kitchen-mark.is-focused {
|
||||
|
||||
@@ -61,6 +61,7 @@ import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { BATCHES as SHARED_BATCHES } from 'utils/batchBucket';
|
||||
import { getConsignmentLogs } from 'pages/api/doormileApi';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { FLOW } from '@/lib/orderFlow';
|
||||
import {
|
||||
onActivate,
|
||||
getStatusStyle,
|
||||
@@ -156,6 +157,38 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF
|
||||
// the same order under two different keys depending on which code path ran).
|
||||
const pickupName = (o) => o.pickupcustomer || o.kitchen_key || o.locationname || o.pickuplocation || o.pickupsuburb || null;
|
||||
|
||||
// The STABLE identity of an order's collection point — what everything below
|
||||
// groups on. The display NAME is not that identity, for two reasons:
|
||||
//
|
||||
// • Two different facilities can carry the same name, and grouping by name
|
||||
// silently merges them into one pin.
|
||||
// • A customer pickup has no facility name at all. Every customer pickup for
|
||||
// one client used to resolve to the same string (the tenant's name, see
|
||||
// queries.js) and therefore to ONE map pin, placed at whichever of those
|
||||
// doorsteps happened to sort first. Ten collections across the city drew
|
||||
// one marker.
|
||||
//
|
||||
// queries.js supplies `pickuppointkey` — one key per facility, one key per
|
||||
// BOOKING for a customer pickup. The name fallback is kept for order objects
|
||||
// that never went through that normaliser (the optimiser preview builds its
|
||||
// own rows), so those behave exactly as they did before.
|
||||
const pickupPointKey = (o) =>
|
||||
o.pickuppointkey || (pickupName(o) || 'Unknown').toLowerCase().trim();
|
||||
|
||||
// Does this order require the rider to travel out to a non-facility address to
|
||||
// collect? Read from the classification queries.js already did; falls back to
|
||||
// false so an un-normalised row routes the way it always has.
|
||||
const isCustomerPickup = (o) => o.pickupsource === 'customer';
|
||||
|
||||
// Text for the flow chip on an order card, or null for no chip.
|
||||
//
|
||||
// Deliberately silent on a plain hub run. That is the overwhelming majority of
|
||||
// every board, and a chip on every card is a chip on none — the point of the
|
||||
// badge is that a doorstep collection or a point-to-point job stands out from
|
||||
// the wall of ordinary outbound work around it.
|
||||
const flowChipLabel = (o) =>
|
||||
o.ordertype && o.ordertype !== FLOW.FORWARD ? o.ordertypelabel || o.ordertype : null;
|
||||
|
||||
// Named delivery batches — operator's mental model of the day's waves.
|
||||
// Each entry covers a half-open range [startHour, endHour) measured in
|
||||
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
|
||||
@@ -403,19 +436,74 @@ const POPUP_TIMELINE = [
|
||||
{ key: 'deliverytime', label: 'Delivered', final: true }
|
||||
];
|
||||
|
||||
// Build a polyline-ready point list for a sorted trip:
|
||||
// - drop NaN drops
|
||||
// - prepend the first valid pickup we can find (so the line starts at the kitchen)
|
||||
const buildTripPoints = (sorted) => {
|
||||
const valid = sorted.filter(hasValidDrop);
|
||||
if (!valid.length) return [];
|
||||
const pickupSrc = sorted.find(hasValidPickup);
|
||||
const pts = [];
|
||||
if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]);
|
||||
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
|
||||
return pts;
|
||||
// Two points close enough to be the same physical visit. A hub pickup copies
|
||||
// the facility's coordinates onto every booking, so consecutive stops off one
|
||||
// kitchen are bit-identical; the tolerance covers a re-pinned gate.
|
||||
const TRIP_POINT_MERGE_M = 50;
|
||||
|
||||
const isSameTripPoint = (a, b) => {
|
||||
if (!a || !b) return false;
|
||||
const R = 6371000;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLon = toRad(b[1] - a[1]);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)) <= TRIP_POINT_MERGE_M;
|
||||
};
|
||||
|
||||
// Build a polyline-ready point list for a sorted trip, as the sequence of
|
||||
// physical visits the rider actually makes.
|
||||
//
|
||||
// This used to be [one pickup, drop, drop, drop, …] — find the FIRST order with
|
||||
// usable pickup coordinates, put it at the front, then chain every drop. That
|
||||
// is only true when every stop is collected from the same place, which is true
|
||||
// for a hub run and false for everything else. A customer pickup collects at a
|
||||
// doorstep, so its collection point is a real stop on the route; drawing the
|
||||
// rider's line straight from the kitchen to the delivery address skipped the
|
||||
// entire first-mile leg and under-reported the distance it covers.
|
||||
//
|
||||
// The rule now: a collection point is emitted when the rider actually travels
|
||||
// to it — always for a customer pickup (each doorstep is its own visit), and
|
||||
// for a facility only when the rider is not already loaded there (first stop,
|
||||
// or a switch to a different kitchen, which is the transition the sidebar
|
||||
// already marks). Consecutive coincident points are merged.
|
||||
//
|
||||
// Returns the drop's index in `points` alongside, because callers colour
|
||||
// per-step segments and previously had to INFER whether a pickup was prepended
|
||||
// by comparing array lengths (`points.length > drops.length`). That inference
|
||||
// held only while at most one pickup could ever appear.
|
||||
const buildTripLegs = (sorted) => {
|
||||
const valid = (sorted || []).filter(hasValidDrop);
|
||||
const points = [];
|
||||
const dropIndices = [];
|
||||
let loadedAtKey = null;
|
||||
|
||||
const push = (pt) => {
|
||||
const last = points[points.length - 1];
|
||||
if (isSameTripPoint(last, pt)) return points.length - 1;
|
||||
points.push(pt);
|
||||
return points.length - 1;
|
||||
};
|
||||
|
||||
valid.forEach((o) => {
|
||||
if (hasValidPickup(o)) {
|
||||
const key = pickupPointKey(o);
|
||||
if (isCustomerPickup(o) || key !== loadedAtKey) {
|
||||
push([toNum(pickupLat(o)), toNum(pickupLon(o))]);
|
||||
}
|
||||
// A doorstep collection never leaves the rider "loaded" anywhere they can
|
||||
// collect again, so it must not suppress the next facility visit.
|
||||
loadedAtKey = isCustomerPickup(o) ? null : key;
|
||||
}
|
||||
dropIndices.push(push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
|
||||
});
|
||||
|
||||
return { points, dropIndices, orders: valid };
|
||||
};
|
||||
|
||||
const buildTripPoints = (sorted) => buildTripLegs(sorted).points;
|
||||
|
||||
const getTripCacheKey = (riderId, tripKey, points) => {
|
||||
if (!points || points.length === 0) return `${riderId}-${tripKey}`;
|
||||
const pointsSig = points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|');
|
||||
@@ -1789,10 +1877,13 @@ const Dispatch = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Collection points on the map. Keyed by pickupPointKey, NOT by display
|
||||
// name: a customer pickup gets its own key per booking, so ten doorstep
|
||||
// collections draw ten pins instead of one pin at an arbitrary one of them.
|
||||
const kitchenMap = {};
|
||||
orders.forEach((o) => {
|
||||
const name = pickupName(o) || 'Unknown';
|
||||
const key = name.toLowerCase().trim();
|
||||
const key = pickupPointKey(o);
|
||||
if (!kitchenMap[key]) {
|
||||
kitchenMap[key] = {
|
||||
id: key,
|
||||
@@ -1800,10 +1891,16 @@ const Dispatch = ({
|
||||
lat: toNum(pickupLat(o)),
|
||||
lon: toNum(pickupLon(o)),
|
||||
orders: [],
|
||||
riders: new Set()
|
||||
riders: new Set(),
|
||||
// Drives the marker: a facility and a doorstep collection are not the
|
||||
// same kind of place and must not read as the same kind of pin.
|
||||
isCustomerPickup: isCustomerPickup(o),
|
||||
flow: o.ordertype || null
|
||||
};
|
||||
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
|
||||
// Upgrade to first valid pickup coords we see for this kitchen
|
||||
// Upgrade to first valid pickup coords we see for this kitchen. Safe
|
||||
// now in a way it was not before: sharing a key means genuinely being
|
||||
// the same facility, rather than merely sharing a tenant.
|
||||
kitchenMap[key].lat = toNum(pickupLat(o));
|
||||
kitchenMap[key].lon = toNum(pickupLon(o));
|
||||
}
|
||||
@@ -2611,7 +2708,7 @@ const Dispatch = ({
|
||||
Object.entries(trips).forEach(([tNum, tOrders]) => {
|
||||
// Filter orders by focused kitchen if active
|
||||
const filteredTOrders = focusedKitchen
|
||||
? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
|
||||
? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id)
|
||||
: tOrders;
|
||||
|
||||
if (filteredTOrders.length === 0) return;
|
||||
@@ -2626,7 +2723,8 @@ const Dispatch = ({
|
||||
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||||
|
||||
// Aerial fallback — NaN-safe build
|
||||
const aerialPath = buildTripPoints(sorted);
|
||||
const aerialLegs = buildTripLegs(sorted);
|
||||
const aerialPath = aerialLegs.points;
|
||||
|
||||
const isKitchenAerial = viewMode === 'kitchens' || focusedKitchen;
|
||||
const path = roadPath || aerialPath;
|
||||
@@ -2645,15 +2743,20 @@ const Dispatch = ({
|
||||
const stepSegs = roadPath
|
||||
? splitPolylineByDrops(roadPath, dropCoords)
|
||||
: (() => {
|
||||
const hasPickup = aerialPath.length > dropCoords.length;
|
||||
// Each step's segment runs from wherever the rider was after the
|
||||
// previous drop to this drop — including any collection point
|
||||
// inserted in between, which is the whole first-mile leg on a
|
||||
// customer pickup. Read from the indices buildTripLegs returns;
|
||||
// this was previously inferred from `path.length > drops.length`,
|
||||
// which silently assumed at most one pickup existed in the path.
|
||||
const out = [];
|
||||
for (let i = 0; i < dropCoords.length; i++) {
|
||||
const a = hasPickup ? i : i - 1;
|
||||
const b = hasPickup ? i + 1 : i;
|
||||
if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) {
|
||||
const b = aerialLegs.dropIndices[i];
|
||||
const a = i === 0 ? 0 : aerialLegs.dropIndices[i - 1];
|
||||
if (b == null || a == null || b >= aerialPath.length || a >= b) {
|
||||
out.push([]);
|
||||
} else {
|
||||
out.push([aerialPath[a], aerialPath[b]]);
|
||||
out.push(aerialPath.slice(a, b + 1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -2771,21 +2874,28 @@ const Dispatch = ({
|
||||
const KITCHEN_ICON_SIZE = 31;
|
||||
const KITCHEN_ICON_FOCUSED_SIZE = 38;
|
||||
|
||||
const createKitchenIcon = (name, focused = false) => {
|
||||
// Collection-point marker. `customerPickup` swaps the amber facility glow for
|
||||
// a violet one and the initial for an arrow, because a doorstep collection
|
||||
// and a kitchen are not the same kind of place: a dispatcher scanning the map
|
||||
// has to be able to tell "load here, it is ours" from "go and collect here"
|
||||
// without opening the pin.
|
||||
const createKitchenIcon = (name, focused = false, customerPickup = false) => {
|
||||
const size = focused ? KITCHEN_ICON_FOCUSED_SIZE : KITCHEN_ICON_SIZE;
|
||||
const anchor = Math.round(size / 2);
|
||||
const border = focused ? 3 : 2;
|
||||
const glow = 14;
|
||||
const rgb = customerPickup ? '139,92,246' : '245,158,11';
|
||||
const glyph = customerPickup ? '↑' : (name || 'K').charAt(0).toUpperCase();
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [anchor, anchor],
|
||||
popupAnchor: [0, -(anchor + 2)],
|
||||
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}" style="width:${size}px;height:${size}px;font-size:${Math.round(
|
||||
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}${
|
||||
customerPickup ? ' is-customer-pickup' : ''
|
||||
}" style="width:${size}px;height:${size}px;font-size:${Math.round(
|
||||
size * 0.38
|
||||
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(245,158,11,0.8), 0 0 ${glow * 2}px rgba(245,158,11,0.4)">${(name || 'K')
|
||||
.charAt(0)
|
||||
.toUpperCase()}</div>`
|
||||
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(${rgb},0.8), 0 0 ${glow * 2}px rgba(${rgb},0.4)">${glyph}</div>`
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3239,7 +3349,7 @@ const Dispatch = ({
|
||||
Object.entries(trips).forEach(([tNum, tOrders]) => {
|
||||
// Filter orders by focused kitchen if active
|
||||
const filteredTOrders = focusedKitchen
|
||||
? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
|
||||
? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id)
|
||||
: tOrders;
|
||||
|
||||
if (filteredTOrders.length === 0) return;
|
||||
@@ -3293,7 +3403,8 @@ const Dispatch = ({
|
||||
// line is never invisible while OSRM is in flight.
|
||||
if (!isAllActiveView && !hasRoad && !failed) return;
|
||||
|
||||
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : buildTripPoints(sorted);
|
||||
const plannedLegs = buildTripLegs(sorted);
|
||||
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : plannedLegs.points;
|
||||
if (!finalPoints || finalPoints.length < 2) return;
|
||||
|
||||
const isKitchenView = viewMode === 'kitchens' || focusedKitchen;
|
||||
@@ -3321,22 +3432,24 @@ const Dispatch = ({
|
||||
const validDrops = sorted.filter(hasValidDrop);
|
||||
const dropCoords = validDrops.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||||
// hasRoad: split the OSRM polyline at each drop's nearest index.
|
||||
// !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints
|
||||
// only prepends the pickup when one's available. Detect that and
|
||||
// align segment[i] with validDrops[i] either way.
|
||||
// !hasRoad: align segment[i] with validDrops[i] using the drop indices
|
||||
// buildTripLegs returns. This was previously inferred from
|
||||
// `finalPoints.length > dropCoords.length`, which assumed the path
|
||||
// held at most one pickup — true for a pure hub run, wrong as soon as
|
||||
// a trip carries doorstep collections, where every extra collection
|
||||
// point shifted every later segment onto the wrong step's colour.
|
||||
let segments;
|
||||
if (hasRoad) {
|
||||
segments = splitPolylineByDrops(finalPoints, dropCoords);
|
||||
} else {
|
||||
const hasPickup = finalPoints.length > dropCoords.length;
|
||||
segments = [];
|
||||
for (let i = 0; i < dropCoords.length; i++) {
|
||||
const idxA = hasPickup ? i : i - 1;
|
||||
const idxB = hasPickup ? i + 1 : i;
|
||||
if (idxA < 0 || idxA >= finalPoints.length || idxB >= finalPoints.length) {
|
||||
const idxB = plannedLegs.dropIndices[i];
|
||||
const idxA = i === 0 ? 0 : plannedLegs.dropIndices[i - 1];
|
||||
if (idxB == null || idxA == null || idxB >= finalPoints.length || idxA >= idxB) {
|
||||
segments.push([]);
|
||||
} else {
|
||||
segments.push([finalPoints[idxA], finalPoints[idxB]]);
|
||||
segments.push(finalPoints.slice(idxA, idxB + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4369,7 +4482,7 @@ const Dispatch = ({
|
||||
</div>
|
||||
<div className="zone-order-grid">
|
||||
{displayOrders.map((o, idx) => {
|
||||
const kitchenKey = (pickupName(o) || 'Unknown').toLowerCase().trim();
|
||||
const kitchenKey = pickupPointKey(o);
|
||||
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
|
||||
prevKitchenKey = kitchenKey;
|
||||
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
|
||||
@@ -4401,7 +4514,8 @@ const Dispatch = ({
|
||||
<span className="kt-ico">
|
||||
<MdSwapHoriz />
|
||||
</span>{' '}
|
||||
Switch to <strong>{o.pickupcustomer}</strong>
|
||||
{isCustomerPickup(o) ? 'Collect at' : 'Switch to'}{' '}
|
||||
<strong>{o.pickupcustomer}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -4536,11 +4650,11 @@ const Dispatch = ({
|
||||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||||
</span>
|
||||
)}
|
||||
{o.ordertype && (
|
||||
{flowChipLabel(o) && (
|
||||
<span
|
||||
className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}
|
||||
>
|
||||
{o.ordertype}
|
||||
{flowChipLabel(o)}
|
||||
</span>
|
||||
)}
|
||||
<span className="zone-order-chip zone-order-trip">
|
||||
@@ -4742,9 +4856,9 @@ const Dispatch = ({
|
||||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||||
</span>
|
||||
)}
|
||||
{o.ordertype && (
|
||||
{flowChipLabel(o) && (
|
||||
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
|
||||
{o.ordertype}
|
||||
{flowChipLabel(o)}
|
||||
</span>
|
||||
)}
|
||||
<span className="zone-order-chip zone-order-trip">
|
||||
@@ -4936,9 +5050,9 @@ const Dispatch = ({
|
||||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||||
</span>
|
||||
)}
|
||||
{o.ordertype && (
|
||||
{flowChipLabel(o) && (
|
||||
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
|
||||
{o.ordertype}
|
||||
{flowChipLabel(o)}
|
||||
</span>
|
||||
)}
|
||||
<span className="zone-order-chip zone-order-trip">
|
||||
@@ -5218,7 +5332,7 @@ const Dispatch = ({
|
||||
<Marker
|
||||
key={`k-${i}`}
|
||||
position={[k.lat, k.lon]}
|
||||
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id)}
|
||||
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id, k.isCustomerPickup)}
|
||||
zIndexOffset={focusedKitchen?.id === k.id ? 4000 : 2000}
|
||||
eventHandlers={{
|
||||
click: () => setFocusedKitchen(k),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { validateHubForm } from '@/lib/hubForm';
|
||||
import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
@@ -114,23 +115,19 @@ export default function Hubs() {
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.hubname.trim()) nextErrors.hubname = 'Give the hub a name';
|
||||
if (form.applocationid === '' || Number.isNaN(Number(form.applocationid))) {
|
||||
nextErrors.applocationid = 'Enter the numeric city id';
|
||||
}
|
||||
/* A hub is a BASE to a rider — the six fields the backend hands the rider
|
||||
app to name, describe and navigate to it. validateHubForm holds why each
|
||||
one is required; it lives in lib/ so the rule can be tested on its own. */
|
||||
const { errors: nextErrors, isValid, values } = validateHubForm(form);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
if (!isValid) return;
|
||||
|
||||
const { hubid, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
applocationid: Number(rest.applocationid),
|
||||
/* Coordinates are optional in the form but numeric on the wire — an empty
|
||||
string here is rejected, whereas 0 is accepted and simply means the hub
|
||||
has not been pinned on the map yet. */
|
||||
latitude: Number(rest.latitude) || 0,
|
||||
longitude: Number(rest.longitude) || 0,
|
||||
applocationid: values.applocationid,
|
||||
latitude: values.latitude,
|
||||
longitude: values.longitude,
|
||||
};
|
||||
|
||||
const mutation = hubid ? updateHub : createHub;
|
||||
@@ -352,19 +349,21 @@ export default function Hubs() {
|
||||
<Input value={form.contactno} onChange={(e) => set('contactno')(e.target.value)} placeholder="9876543210" />
|
||||
</Field>
|
||||
|
||||
<Field label="Address" className="sm:col-span-2">
|
||||
<Field label="Address" required error={errors.address} className="sm:col-span-2">
|
||||
<Input value={form.address} onChange={(e) => set('address')(e.target.value)} placeholder="Street, area, landmark" />
|
||||
</Field>
|
||||
|
||||
<Field label="Pincode">
|
||||
<Field label="Pincode" required error={errors.pincode}>
|
||||
<Input value={form.pincode} onChange={(e) => set('pincode')(e.target.value)} placeholder="641004" />
|
||||
</Field>
|
||||
|
||||
{/* Riders navigate to these. A hub saved without them is a base the
|
||||
app can name but cannot route to. */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Latitude">
|
||||
<Field label="Latitude" required error={errors.latitude}>
|
||||
<Input value={form.latitude} onChange={(e) => set('latitude')(e.target.value)} placeholder="11.0168" />
|
||||
</Field>
|
||||
<Field label="Longitude">
|
||||
<Field label="Longitude" required error={errors.longitude}>
|
||||
<Input value={form.longitude} onChange={(e) => set('longitude')(e.target.value)} placeholder="76.9558" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
194
src/pages/doormile/orders/BookingDestinationsDrawer.jsx
Normal file
194
src/pages/doormile/orders/BookingDestinationsDrawer.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import { MapPin, Package } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Drawer, EmptyState, LoadingState, StatusBadge } from '@/components/ds';
|
||||
import { getBooking } from '@/api/doormile/endpoints';
|
||||
import { currency } from '@/lib/doormileFormat';
|
||||
import { plural } from '@/lib/bookingDrops';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
/**
|
||||
* The legs of one pickup.
|
||||
*
|
||||
* A customer-app pickup is ONE booking carrying N destinations. The list can
|
||||
* only say how many (`destinationcount`); the array itself is fetched here, on
|
||||
* open, because the Orders list drains up to twelve pages of a hundred rows and
|
||||
* would otherwise carry an array it never reads.
|
||||
*
|
||||
* Two things this deliberately does NOT do:
|
||||
*
|
||||
* - It never reads `booking.consignmentid` or `booking.consignmentstatus`.
|
||||
* Pickup-complete sets those from the FIRST leg only, for the benefit of
|
||||
* older single-drop screens, so on a three-drop pickup they are Chennai's
|
||||
* status wearing the whole booking's name. Per-leg state comes from the
|
||||
* destination's own `stage` and `consignmentid`, never from the booking's.
|
||||
*
|
||||
* - It never invents a tracking number, stage or ETA. Those are minted at
|
||||
* pickup-complete (cxPickupFanout), so before the rider collects there is
|
||||
* genuinely nothing to show, and a column of em dashes reads as missing
|
||||
* data rather than as work that has not happened yet. The columns are
|
||||
* dropped instead.
|
||||
*/
|
||||
|
||||
/* The address the customer typed, in the order they typed it. Empty parts are
|
||||
dropped rather than rendered as gaps: a destination may carry nothing but a
|
||||
district until the customer completes it. */
|
||||
const streetAddress = (destination) =>
|
||||
[destination?.building, destination?.street, destination?.landmark]
|
||||
.map((part) => String(part || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const DestinationRow = ({ destination, index, showShipmentColumns }) => {
|
||||
const address = streetAddress(destination);
|
||||
const expected = parseDoormileTimestamp(destination?.expecteddeliveryat);
|
||||
const delivered = parseDoormileTimestamp(destination?.deliveredat);
|
||||
const cod = Number(destination?.codamount) || 0;
|
||||
const weight = Number(destination?.verifiedweightkg) || 0;
|
||||
|
||||
return (
|
||||
<li className="flex gap-3 border-b border-border py-3 last:border-0">
|
||||
{/* seq is the customer-facing position and the {index} in
|
||||
PATCH /customer/bookings/{ref}/destinations/{index}. Shown one-based
|
||||
as the customer counts them, and never re-sorted. */}
|
||||
<span className="mt-0.5 shrink-0 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p className="text-body-sm font-semibold text-ink-1">
|
||||
{destination?.districtname || 'Destination'}
|
||||
</p>
|
||||
{destination?.statename && (
|
||||
<span className="text-caption text-ink-3">{destination.statename}</span>
|
||||
)}
|
||||
<span className="text-caption text-ink-3">
|
||||
{`· ${plural(Number(destination?.packagecount) || 0, 'package')}`}
|
||||
</span>
|
||||
{showShipmentColumns && destination?.stage && (
|
||||
<StatusBadge status={destination.stage} dot size="sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{address && (
|
||||
<p className="truncate text-caption text-ink-2" title={address}>
|
||||
{address}
|
||||
{destination?.pincode ? ` · ${destination.pincode}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(destination?.recipientname || destination?.recipientphone) && (
|
||||
<p className="text-caption text-ink-2">
|
||||
{destination.recipientname || 'Recipient'}
|
||||
{destination.recipientphone ? ` · ${destination.recipientphone}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{destination?.instructions && (
|
||||
<p className="text-caption italic text-ink-3">{destination.instructions}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
|
||||
{showShipmentColumns && destination?.trackingno && (
|
||||
<span className="font-medium tabular-nums text-ink-2">{destination.trackingno}</span>
|
||||
)}
|
||||
{cod > 0 && <span>{`COD ${currency(cod)}`}</span>}
|
||||
{weight > 0 && <span>{`${weight} kg verified`}</span>}
|
||||
{delivered.isValid() ? (
|
||||
<span>{`Delivered ${delivered.format('DD MMM, hh:mm A')}`}</span>
|
||||
) : (
|
||||
expected.isValid() && <span>{`Expected ${expected.format('DD MMM, hh:mm A')}`}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export function BookingDestinationsDrawer({ bookingId, bookingNo, open, onOpenChange }) {
|
||||
/* Fetched on open, never on list render. `enabled` is what keeps this from
|
||||
firing for every row the operator scrolls past. */
|
||||
const { data: booking, isLoading, isError } = useQuery({
|
||||
queryKey: ['doormile', 'booking', bookingId],
|
||||
queryFn: () => getBooking(bookingId),
|
||||
enabled: Boolean(open && bookingId),
|
||||
});
|
||||
|
||||
const destinations = Array.isArray(booking?.destinations) ? booking.destinations : [];
|
||||
|
||||
/* Tracking numbers and stages are minted together at pickup-complete, so
|
||||
"any leg has one" is the same question as "has this pickup been collected".
|
||||
Asked across all legs rather than per row, so the columns appear or vanish
|
||||
as a set instead of ragged. */
|
||||
const showShipmentColumns = destinations.some(
|
||||
(destination) => destination?.trackingno || destination?.stage
|
||||
);
|
||||
|
||||
const totalPackages = destinations.reduce(
|
||||
(sum, destination) => sum + (Number(destination?.packagecount) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
icon={MapPin}
|
||||
title={bookingNo ? `${bookingNo} · drops` : 'Drops'}
|
||||
description={
|
||||
destinations.length
|
||||
? `${plural(destinations.length, 'destination')} · ${plural(totalPackages, 'package')}`
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
<div className="px-5 py-4">
|
||||
{isLoading && <LoadingState message="Loading drops" />}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Could not load the drops"
|
||||
description="The booking detail request failed. Close this and try again."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && destinations.length === 0 && (
|
||||
/* Two different situations reach here and the copy has to fit both: a
|
||||
console-created booking genuinely has no destination rows, and a
|
||||
console running ahead of the backend gets no `destinations` key at
|
||||
all. Neither is an error worth alarming an operator about. */
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No separate drops recorded"
|
||||
description="This booking has a single delivery address, shown on the row itself."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && destinations.length > 0 && (
|
||||
<>
|
||||
{!showShipmentColumns && (
|
||||
<p className="mb-3 text-caption text-ink-3">
|
||||
Tracking numbers appear once the rider completes pickup. Each drop becomes its own
|
||||
shipment then.
|
||||
</p>
|
||||
)}
|
||||
<ol className="min-w-0">
|
||||
{destinations.map((destination, index) => (
|
||||
<DestinationRow
|
||||
key={destination?.bookingdestinationid ?? index}
|
||||
destination={destination}
|
||||
index={index}
|
||||
showShipmentColumns={showShipmentColumns}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingDestinationsDrawer;
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import AddressAutocomplete, { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
|
||||
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
|
||||
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
|
||||
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
|
||||
import {
|
||||
createExpressBooking,
|
||||
@@ -30,6 +31,14 @@ import {
|
||||
getTenantLocations
|
||||
} from '@/api/doormile/endpoints';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
import { useHubs } from '@/lib/doormileHooks';
|
||||
import {
|
||||
PICKUP_SOURCE,
|
||||
buildAnchors,
|
||||
buildFlowFields,
|
||||
flowForDraft,
|
||||
flowLabel
|
||||
} from '@/lib/orderFlow';
|
||||
import { OpenToast } from '@/components/third-party/OpenToast';
|
||||
|
||||
const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
|
||||
@@ -95,6 +104,13 @@ export default function CreateOrder() {
|
||||
const [pickCust, setPickCust] = useState(EMPTY_PARTY);
|
||||
const [dropCust, setDropCust] = useState(EMPTY_PARTY);
|
||||
|
||||
// Where the parcel is collected from. 'hub' copies the selected business
|
||||
// location onto the pickup block, which is what this form has always done.
|
||||
// 'customer' leaves the pickup fields to the operator, because the collection
|
||||
// point is a doorstep that no saved location describes.
|
||||
const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB);
|
||||
const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER;
|
||||
|
||||
const [pickupDate, setPickupDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
const [selectedSlotTime, setSelectedSlotTime] = useState('09:00');
|
||||
const [serviceOption, setServiceOption] = useState('Normal');
|
||||
@@ -109,6 +125,9 @@ export default function CreateOrder() {
|
||||
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
|
||||
const [searchCustQuery, setSearchCustQuery] = useState('');
|
||||
|
||||
// Map pin picker modal target ('pickup' | 'drop' | null)
|
||||
const [mapPickerTarget, setMapPickerTarget] = useState(null);
|
||||
|
||||
// Queries
|
||||
const { data: staffTenants = [], isLoading: isTenantsLoading } = useQuery({
|
||||
queryKey: ['adminTenants'],
|
||||
@@ -132,6 +151,17 @@ export default function CreateOrder() {
|
||||
queryFn: getAdminPricing
|
||||
});
|
||||
|
||||
// Facilities, so a DROP that lands on one is recognised as a hub rather than
|
||||
// treated as a customer's address. Without this, a rider bringing a parcel
|
||||
// back to the hub would be booked as point-to-point and routed as if the hub
|
||||
// were somebody's house.
|
||||
const { data: hubList = [] } = useHubs();
|
||||
|
||||
const flowAnchors = useMemo(
|
||||
() => buildAnchors({ hubs: hubList, tenantLocations: tenantLocations }),
|
||||
[hubList, tenantLocations]
|
||||
);
|
||||
|
||||
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
|
||||
queryKey: ['adminCustomers'],
|
||||
queryFn: getAdminCustomers,
|
||||
@@ -148,24 +178,30 @@ export default function CreateOrder() {
|
||||
|
||||
const sortedLocations = useMemo(() => sortLocations(tenantLocations), [tenantLocations]);
|
||||
|
||||
// Auto-select single location
|
||||
// Auto-select single location.
|
||||
//
|
||||
// The location is still selected under a customer pickup — it is the client's
|
||||
// servicing origin and the order belongs to it — but its address is NOT
|
||||
// copied over the pickup block, which would silently overwrite the doorstep
|
||||
// the operator just typed with the kitchen's address.
|
||||
useEffect(() => {
|
||||
if (sortedLocations.length === 1) {
|
||||
const loc = sortedLocations[0];
|
||||
setSelectedLocationId(String(loc.tenantlocationid || loc.locationid));
|
||||
setSelectedLocation(loc);
|
||||
applyLocationToPickup(loc);
|
||||
if (!isCustomerPickup) applyLocationToPickup(loc);
|
||||
} else if (sortedLocations.length === 0) {
|
||||
setSelectedLocationId('');
|
||||
setSelectedLocation(null);
|
||||
}
|
||||
}, [sortedLocations]);
|
||||
}, [sortedLocations, isCustomerPickup]);
|
||||
|
||||
const applyLocationToPickup = (loc) => {
|
||||
if (!loc) return;
|
||||
const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat;
|
||||
const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong;
|
||||
|
||||
// @ts-ignore
|
||||
setPickCust((prev) => ({
|
||||
...prev,
|
||||
firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point',
|
||||
@@ -190,9 +226,42 @@ export default function CreateOrder() {
|
||||
}
|
||||
const loc = sortedLocations.find((l) => String(l.tenantlocationid || l.locationid) === locId);
|
||||
setSelectedLocation(loc || null);
|
||||
if (loc) applyLocationToPickup(loc);
|
||||
if (loc && !isCustomerPickup) applyLocationToPickup(loc);
|
||||
};
|
||||
|
||||
// Switching collection mode.
|
||||
//
|
||||
// Going to 'customer' CLEARS the pickup block rather than leaving the hub's
|
||||
// address sitting in it. A half-cleared form is the dangerous state here: the
|
||||
// operator retypes the address, misses the coordinates, and books a doorstep
|
||||
// collection that is still pinned on the kitchen — which routes a rider to
|
||||
// the wrong end of the city and prices the job off the wrong leg.
|
||||
const handleChangePickupMode = (mode) => {
|
||||
if (mode === pickupMode) return;
|
||||
setPickupMode(mode);
|
||||
if (mode === PICKUP_SOURCE.CUSTOMER) {
|
||||
setPickCust(EMPTY_PARTY);
|
||||
} else if (selectedLocation) {
|
||||
applyLocationToPickup(selectedLocation);
|
||||
}
|
||||
};
|
||||
|
||||
// What this order will be booked as. The pickup end is whatever the operator
|
||||
// chose; the drop end is measured, because a free-typed delivery address that
|
||||
// lands on a facility is a return to the hub, not a delivery to a customer.
|
||||
const draftFlow = useMemo(
|
||||
() =>
|
||||
flowForDraft({
|
||||
pickupSource: pickupMode,
|
||||
pickupLat: pickCust.latitude,
|
||||
pickupLng: pickCust.longitude,
|
||||
dropLat: dropCust.latitude,
|
||||
dropLng: dropCust.longitude,
|
||||
anchors: flowAnchors
|
||||
}),
|
||||
[pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors]
|
||||
);
|
||||
|
||||
// Tenant Pricing
|
||||
const clientRate = useMemo(() => {
|
||||
const list = Array.isArray(pricingList) ? pricingList : [];
|
||||
@@ -232,6 +301,45 @@ export default function CreateOrder() {
|
||||
};
|
||||
}, [pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude]);
|
||||
|
||||
// First-mile ("deadhead") leg.
|
||||
//
|
||||
// On a hub run the rider starts where the parcel is, so the only distance
|
||||
// that exists is pickup → drop. A customer pickup adds a leg BEFORE that:
|
||||
// the rider travels from the servicing location out to the customer's door,
|
||||
// carrying nothing. It is real distance, real time and real fuel, and until
|
||||
// now nothing in this console measured it.
|
||||
//
|
||||
// Measured and shown, NOT billed. What to charge for an empty leg is a
|
||||
// tariff decision, and quietly folding it into every customer-pickup order's
|
||||
// price would change what clients are invoiced without anyone choosing that.
|
||||
// `totalCharge` below is deliberately left on the loaded leg alone.
|
||||
const [deadhead, setDeadhead] = useState({ distance: 0, resolved: false });
|
||||
|
||||
useEffect(() => {
|
||||
const oLat = Number(selectedLocation?.latitude);
|
||||
const oLng = Number(selectedLocation?.longitude);
|
||||
const pLat = Number(pickCust.latitude);
|
||||
const pLng = Number(pickCust.longitude);
|
||||
|
||||
if (!isCustomerPickup || !oLat || !oLng || !pLat || !pLng) {
|
||||
setDeadhead({ distance: 0, resolved: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
calculateDrivingDistance({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
|
||||
.then((distance) => {
|
||||
if (active) setDeadhead({ distance: Math.max(0, distance || 0), resolved: true });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setDeadhead({ distance: 0, resolved: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]);
|
||||
|
||||
// Total Charge calculation
|
||||
const totalCharge = useMemo(() => {
|
||||
const base = Number(clientRate?.baseprice) || 40;
|
||||
@@ -248,6 +356,7 @@ export default function CreateOrder() {
|
||||
|
||||
// Place selection helpers
|
||||
const handlePlaceSelect = (partyType, place) => {
|
||||
if (!place) return;
|
||||
const parsed = { suburb: '', city: '', postcode: '' };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
(c.types || []).forEach((type) => {
|
||||
@@ -257,19 +366,22 @@ export default function CreateOrder() {
|
||||
});
|
||||
});
|
||||
|
||||
const lat = place.geometry?.location?.lat ? place.geometry.location.lat() : '';
|
||||
const lng = place.geometry?.location?.lng ? place.geometry.location.lng() : '';
|
||||
const address = place.formatted_address || '';
|
||||
const lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : '');
|
||||
const lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : '');
|
||||
const address = place.formatted_address || place.name || '';
|
||||
const suburb = place.suburb || parsed.suburb;
|
||||
const city = place.city || parsed.city;
|
||||
const postcode = place.postcode || parsed.postcode;
|
||||
|
||||
const setter = partyType === 'pickup' ? setPickCust : setDropCust;
|
||||
setter((prev) => ({
|
||||
...prev,
|
||||
address,
|
||||
suburb: parsed.suburb || prev.suburb,
|
||||
city: parsed.city || prev.city,
|
||||
postcode: parsed.postcode || prev.postcode,
|
||||
latitude: lat,
|
||||
longitude: lng
|
||||
address: address || prev.address,
|
||||
suburb: suburb || prev.suburb,
|
||||
city: city || prev.city,
|
||||
postcode: postcode || prev.postcode,
|
||||
latitude: lat !== '' ? lat : prev.latitude,
|
||||
longitude: lng !== '' ? lng : prev.longitude
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -320,6 +432,21 @@ export default function CreateOrder() {
|
||||
OpenToast('Enter pickup address', 'warning', 2500);
|
||||
return;
|
||||
}
|
||||
// Pickup coordinates were never checked here. That was survivable only
|
||||
// while the pickup was always a saved location, which supplies its own
|
||||
// coordinates — a hand-typed collection address that fails to geocode
|
||||
// submits 0,0, and an order pinned at 0,0 is unroutable, unpriceable and
|
||||
// invisible to every zone filter.
|
||||
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
|
||||
OpenToast(
|
||||
isCustomerPickup
|
||||
? 'Pick the collection address from the suggestions, or drop a map pin, so it has coordinates'
|
||||
: 'The selected hub has no coordinates — set them on the Hubs page before booking from it',
|
||||
'warning',
|
||||
4000
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!dropCust.firstname.trim()) {
|
||||
OpenToast('Enter delivery contact name', 'warning', 2500);
|
||||
return;
|
||||
@@ -369,7 +496,23 @@ export default function CreateOrder() {
|
||||
service_option: SERVICE_OPTIONS.includes(serviceOption) ? serviceOption : 'Normal',
|
||||
finalprice,
|
||||
notes: notes || '',
|
||||
parcels
|
||||
parcels,
|
||||
// Which flow this is, and which hub owns it. Sent even where the backend
|
||||
// may not store them yet: an unknown key is ignored server-side, and the
|
||||
// console classifies from coordinates in the meantime, so nothing depends
|
||||
// on these landing. The day they are persisted, every order created from
|
||||
// here carries its own type instead of being re-derived on each load.
|
||||
...buildFlowFields({
|
||||
flow: draftFlow.flow,
|
||||
servicingHubId:
|
||||
draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null,
|
||||
tenantLocationId: selectedLocationId,
|
||||
// The collection point itself, so the order records what KIND of place
|
||||
// it leaves from — a base, a client's site, or a customer's door. The
|
||||
// backend stores it and the rider app titles the stop with it; without
|
||||
// it every logistics pickup reads as the rider's own base name.
|
||||
pickupAnchor: draftFlow.originAnchor
|
||||
})
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
@@ -523,10 +666,77 @@ export default function CreateOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLocation && (
|
||||
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
{selectedLocation.locationname || 'Hub Selected'}
|
||||
{/* The badge names the COLLECTION POINT, so it has to stop
|
||||
naming the hub the moment the hub is not where we collect. */}
|
||||
{isCustomerPickup ? (
|
||||
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-violet-50 text-violet-700 border border-violet-200">
|
||||
{flowLabel(draftFlow.flow)}
|
||||
</span>
|
||||
) : (
|
||||
selectedLocation && (
|
||||
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
{selectedLocation.locationname || 'Hub Selected'}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collect from the client's own location, or from a customer's
|
||||
door. This is the switch the whole order flow turns on: it
|
||||
decides whether the pickup block is filled from the saved
|
||||
location or typed, and it is what the board later reads to
|
||||
draw a doorstep collection as its own stop on the route. */}
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3">
|
||||
<p className="text-[11px] font-semibold text-slate-700 mb-2">Collect from</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{
|
||||
mode: PICKUP_SOURCE.HUB,
|
||||
icon: Building2,
|
||||
title: 'Business Hub',
|
||||
hint: 'Client kitchen, depot or branch'
|
||||
},
|
||||
{
|
||||
mode: PICKUP_SOURCE.CUSTOMER,
|
||||
icon: Users,
|
||||
title: 'Customer Pickup',
|
||||
hint: "Collect at a customer's own address"
|
||||
}
|
||||
].map(({ mode, icon: Icon, title, hint }) => {
|
||||
const active = pickupMode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => handleChangePickupMode(mode)}
|
||||
aria-pressed={active}
|
||||
className={`flex items-start gap-2.5 rounded-lg border p-2.5 text-left transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'border-black bg-white shadow-2xs ring-2 ring-black/10'
|
||||
: 'border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 mt-0.5 shrink-0 ${active ? 'text-slate-900' : 'text-slate-400'}`}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={`block text-xs font-bold ${active ? 'text-slate-900' : 'text-slate-600'}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className="block text-[10px] text-slate-500 leading-tight mt-0.5">{hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isCustomerPickup && (
|
||||
<p className="mt-2 text-[10px] text-violet-700 bg-violet-50 border border-violet-200 rounded-lg px-2.5 py-1.5 leading-relaxed">
|
||||
The rider travels out to this address to collect. Pick it from the suggestions or drop a
|
||||
map pin so it carries coordinates — the first-mile leg is priced and routed from them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -566,10 +776,11 @@ export default function CreateOrder() {
|
||||
id="pickup-address"
|
||||
label="Pickup Address"
|
||||
required
|
||||
placeholder="Search address or type location"
|
||||
placeholder="Search address, building or locality"
|
||||
value={pickCust.address}
|
||||
onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))}
|
||||
onPlaceSelected={(place) => handlePlaceSelect('pickup', place)}
|
||||
onOpenMapPin={() => setMapPickerTarget('pickup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -676,11 +887,12 @@ export default function CreateOrder() {
|
||||
id="drop-address"
|
||||
label="Delivery Address"
|
||||
required
|
||||
placeholder="Search delivery address or locality"
|
||||
placeholder="Search delivery address, society or landmark"
|
||||
value={dropCust.address}
|
||||
bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined}
|
||||
onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))}
|
||||
onPlaceSelected={(place) => handlePlaceSelect('drop', place)}
|
||||
onOpenMapPin={() => setMapPickerTarget('drop')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -970,11 +1182,35 @@ export default function CreateOrder() {
|
||||
|
||||
{/* Journey Legs */}
|
||||
<div className="space-y-3 py-1 text-xs">
|
||||
{/* The empty run out to the customer's door. Drawn as its own
|
||||
leg because that is what it is — the rider covers it before
|
||||
the parcel exists on the trip. */}
|
||||
{isCustomerPickup && selectedLocation && (
|
||||
<>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 ring-4 ring-slate-100 mt-1 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-slate-500 truncate">
|
||||
{selectedLocation.locationname || 'Servicing Hub'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 line-clamp-1">
|
||||
Rider starts here · travels empty
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 pl-2.5 border-l border-dashed border-slate-200 h-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500 ring-4 ring-emerald-100 mt-1 shrink-0" />
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full mt-1 shrink-0 ${
|
||||
isCustomerPickup ? 'bg-violet-500 ring-4 ring-violet-100' : 'bg-emerald-500 ring-4 ring-emerald-100'
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-slate-900 truncate">
|
||||
{pickCust.firstname || selectedLocation?.locationname || 'Pickup Point'}
|
||||
{pickCust.firstname || (isCustomerPickup ? 'Collection Point' : selectedLocation?.locationname) || 'Pickup Point'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 line-clamp-1">{pickCust.address || 'Address not selected'}</div>
|
||||
</div>
|
||||
@@ -1010,6 +1246,22 @@ export default function CreateOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shown only when it exists, and labelled as unbilled so nobody
|
||||
reconciles the invoice against the wrong number. */}
|
||||
{isCustomerPickup && deadhead.resolved && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-violet-200 bg-violet-50 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-bold text-violet-700 uppercase tracking-wider">
|
||||
First-mile leg
|
||||
</div>
|
||||
<div className="text-[10px] text-violet-600 leading-tight">
|
||||
Hub → collection point · not billed
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{deadhead.distance} km</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate Card Info */}
|
||||
<div className="text-xs space-y-2 py-2 border-y border-slate-100">
|
||||
<div className="flex justify-between text-slate-600">
|
||||
@@ -1155,6 +1407,22 @@ export default function CreateOrder() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map Pin Picker Modal for Pinpoint Accuracy */}
|
||||
<MapPinPickerModal
|
||||
isOpen={Boolean(mapPickerTarget)}
|
||||
onClose={() => setMapPickerTarget(null)}
|
||||
title={mapPickerTarget === 'pickup' ? 'Pin Exact Pickup Location' : 'Pin Exact Delivery Location'}
|
||||
initialAddress={mapPickerTarget === 'pickup' ? pickCust.address : dropCust.address}
|
||||
initialLat={mapPickerTarget === 'pickup' ? pickCust.latitude : dropCust.latitude}
|
||||
initialLng={mapPickerTarget === 'pickup' ? pickCust.longitude : dropCust.longitude}
|
||||
onConfirm={(place) => {
|
||||
if (mapPickerTarget) {
|
||||
handlePlaceSelect(mapPickerTarget, place);
|
||||
}
|
||||
setMapPickerTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,11 +38,13 @@ import {
|
||||
requiredSheetColumns
|
||||
} from '@/lib/bulkOrderColumns';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
|
||||
import { useHubs } from '@/lib/doormileHooks';
|
||||
import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow';
|
||||
import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload';
|
||||
import { OpenToast } from '@/components/third-party/OpenToast';
|
||||
|
||||
// Nominatim serves this console's geocoding for free and asks for no more than
|
||||
// one request a second in return. See handleProcessUsersToDrops.
|
||||
const GEOCODE_INTERVAL_MS = 1100;
|
||||
// Multi-provider geocoding service allows sub-300ms lookups
|
||||
const GEOCODE_INTERVAL_MS = 250;
|
||||
|
||||
const buildDefaultPickupSlots = () => {
|
||||
const arr = [];
|
||||
@@ -85,6 +87,21 @@ export default function MultipleOrders() {
|
||||
const [pricePerKm, setPricePerKm] = useState(0);
|
||||
const [minKm, setMinKm] = useState(0);
|
||||
|
||||
// Where every row in this run is collected from.
|
||||
//
|
||||
// 'hub' — one shared collection point, the selected business location.
|
||||
// This is what the page has always done, and stays the default.
|
||||
// 'customer' — each ROW carries its own collection point, read from the
|
||||
// sheet's Sender Name / Sender Phone / Sender Address columns.
|
||||
//
|
||||
// Those sender columns are not new. SHEET_HEADER_MAP has always mapped them
|
||||
// (to `locationname` / `locationcontact` / `locationaddress`) and the page has
|
||||
// always parsed them and then thrown them away, overwriting every row's
|
||||
// origin with the hub. A tenant sending a file of doorstep collections was
|
||||
// therefore booking every one of them against the kitchen.
|
||||
const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB);
|
||||
const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER;
|
||||
|
||||
// Upload & selection state
|
||||
const [uploadType, setUploadType] = useState(null); // 0: file, 1: selection
|
||||
const [fileName, setFileName] = useState('');
|
||||
@@ -120,6 +137,13 @@ export default function MultipleOrders() {
|
||||
queryFn: getAdminPricing
|
||||
});
|
||||
|
||||
const { data: hubList = [] } = useHubs();
|
||||
|
||||
const flowAnchors = useMemo(
|
||||
() => buildAnchors({ hubs: hubList, tenantLocations }),
|
||||
[hubList, tenantLocations]
|
||||
);
|
||||
|
||||
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
|
||||
queryKey: ['adminCustomers'],
|
||||
queryFn: getAdminCustomers,
|
||||
@@ -169,11 +193,24 @@ export default function MultipleOrders() {
|
||||
};
|
||||
|
||||
// Distance calculation helper
|
||||
// The collection point for ONE row.
|
||||
//
|
||||
// `__pickup` is attached per row during processing when the run collects from
|
||||
// customers; it travels on the row rather than on the page so a file can mix
|
||||
// origins without one row silently re-addressing another. Falls back to the
|
||||
// shared business location, which is every row's answer on a hub run.
|
||||
const rowPickup = (customer) => rowPickupOf(customer, pickCust);
|
||||
|
||||
const calculateDistance = async (customer) => {
|
||||
if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.');
|
||||
if (!pickCust || typeof pickCust !== 'object') throw new Error('Pickup location missing.');
|
||||
const origin = rowPickup(customer);
|
||||
if (!origin || typeof origin !== 'object') throw new Error('Pickup location missing.');
|
||||
|
||||
const roundedDistance = await calculateDrivingDistance(pickCust, customer);
|
||||
// Measured from THIS row's collection point. Measuring every row from the
|
||||
// hub while collecting at a doorstep prices the wrong leg entirely — for a
|
||||
// collection near the recipient it bills a cross-town run that no rider
|
||||
// makes, and for one far from both it under-bills.
|
||||
const roundedDistance = await calculateDrivingDistance(origin, customer);
|
||||
const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm);
|
||||
return { roundedDistance, totalcharge };
|
||||
};
|
||||
@@ -275,6 +312,12 @@ export default function MultipleOrders() {
|
||||
if (!users.length) return;
|
||||
setIsProcessingRows(true);
|
||||
const newDrops = [];
|
||||
// One lookup per DISTINCT sender address, not per row. A real tenant file
|
||||
// is three home kitchens across thirteen drops — geocoding the same kitchen
|
||||
// six times costs six rate-limited round trips for one answer, and risks
|
||||
// the six rows landing on marginally different coordinates, which would
|
||||
// then draw six collection pins where the operator expects one.
|
||||
const senderPlaceCache = new Map();
|
||||
// Set once the first address lookup has gone out, so the pause below is
|
||||
// paid between lookups rather than before the first one.
|
||||
let geocoded = false;
|
||||
@@ -296,14 +339,56 @@ export default function MultipleOrders() {
|
||||
[customer.address, customer.city, customer.postcode].filter(Boolean).join(', '),
|
||||
{ bias: pickCust?.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined }
|
||||
);
|
||||
if (place?.geometry?.location) {
|
||||
lat = place.geometry.location.lat();
|
||||
lng = place.geometry.location.lng();
|
||||
if (place) {
|
||||
lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : null);
|
||||
lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : null);
|
||||
}
|
||||
}
|
||||
|
||||
// The row's own collection point, from the sheet's sender columns.
|
||||
// Geocoded the same way the delivery address is — a pickup without
|
||||
// coordinates is unroutable, and a row that cannot produce them keeps
|
||||
// the shared hub rather than being booked at 0,0.
|
||||
let rowOwnPickup = null;
|
||||
if (isCustomerPickup) {
|
||||
const senderAddress = String(customer.locationaddress || customer.senderaddress || '').trim();
|
||||
if (senderAddress) {
|
||||
if (!senderPlaceCache.has(senderAddress)) {
|
||||
if (geocoded) await new Promise((resolve) => setTimeout(resolve, GEOCODE_INTERVAL_MS));
|
||||
geocoded = true;
|
||||
senderPlaceCache.set(
|
||||
senderAddress,
|
||||
await geocodeAddress(senderAddress, {
|
||||
bias: pickCust?.latitude
|
||||
? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) }
|
||||
: undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
const senderPlace = senderPlaceCache.get(senderAddress);
|
||||
const sLat = senderPlace?.latitude ?? senderPlace?.geometry?.location?.lat?.();
|
||||
const sLng = senderPlace?.longitude ?? senderPlace?.geometry?.location?.lng?.();
|
||||
if (sLat && sLng) {
|
||||
rowOwnPickup = {
|
||||
locationname: customer.locationname || 'Customer Pickup',
|
||||
contactno: String(customer.locationcontact || '').replace(/\D/g, '').slice(0, 10),
|
||||
address: senderPlace.formatted_address || senderAddress,
|
||||
city: senderPlace.city || '',
|
||||
pincode: senderPlace.postcode || '',
|
||||
latitude: sLat,
|
||||
longitude: sLng
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedCustomer = {
|
||||
...customer,
|
||||
__pickup: rowOwnPickup,
|
||||
// Surfaced on the row so an operator reviewing the preview can see
|
||||
// which rows fell back to the hub because their sender address would
|
||||
// not geocode, instead of discovering it after 200 orders are live.
|
||||
__pickupFellBack: isCustomerPickup && !rowOwnPickup,
|
||||
latitude: lat || null,
|
||||
longitude: lng || null,
|
||||
quantity: Number(customer.quantity) || 1,
|
||||
@@ -323,6 +408,19 @@ export default function MultipleOrders() {
|
||||
|
||||
setDropCust(newDrops);
|
||||
setIsProcessingRows(false);
|
||||
|
||||
const fellBack = newDrops.filter((d) => d.__pickupFellBack).length;
|
||||
if (fellBack > 0) {
|
||||
// Named, not silent. These rows will be collected from the hub, which is
|
||||
// a different journey from the one the sheet asked for.
|
||||
OpenToast(
|
||||
`${fellBack} of ${newDrops.length} rows had no usable Sender Address — those will be collected from ${
|
||||
pickCust?.locationname || 'the selected hub'
|
||||
}`,
|
||||
'warning',
|
||||
6000
|
||||
);
|
||||
}
|
||||
OpenToast(`Processed ${newDrops.length} delivery drops`, 'success', 2500);
|
||||
};
|
||||
|
||||
@@ -448,32 +546,20 @@ export default function MultipleOrders() {
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
);
|
||||
|
||||
const bookings = dropCust.map((customer) => ({
|
||||
tenantid: Number(selectedTenantId),
|
||||
pickupaddress: pickCust.address || '',
|
||||
pickuppincode: pickCust.pincode || pickCust.postcode || '',
|
||||
pickupcity: pickCust.city || '',
|
||||
pickuplatitude: Number(pickCust.latitude) || 0,
|
||||
pickuplongitude: Number(pickCust.longitude) || 0,
|
||||
customer_phone: customer.contactno?.toString() || '',
|
||||
customer_name: customer.firstname || '',
|
||||
deliveryaddress: customer.address || '',
|
||||
deliverypincode: customer.postcode?.toString() || '',
|
||||
deliverycity: customer.city || '',
|
||||
deliverylatitude: Number(customer.latitude) || 0,
|
||||
deliverylongitude: Number(customer.longitude) || 0,
|
||||
// The transform lives in lib/bulkOrderPayload so a test can drive the real
|
||||
// thing against a real tenant file, rather than a copy of it that agrees
|
||||
// today. Per row, never collapsed onto one shared origin: a run can mix a
|
||||
// doorstep collection with a hub pickup that fell back, and writing the hub
|
||||
// across all of them would silently re-address half the file.
|
||||
const bookings = buildBulkBookingPayloads({
|
||||
rows: dropCust,
|
||||
sharedPickup: pickCust,
|
||||
tenantId: selectedTenantId,
|
||||
tenantLocationId: selectedLocationId,
|
||||
deliverytime,
|
||||
service_option: 'Normal',
|
||||
finalprice: Number(customer.totalcharge) || 0,
|
||||
notes: otherInstructions || '',
|
||||
parcels: [
|
||||
{
|
||||
itemcategory: 'General',
|
||||
itemdescription: 'Order',
|
||||
declaredvalue: Number(customer.totalcharge) || 0
|
||||
}
|
||||
]
|
||||
}));
|
||||
notes: otherInstructions,
|
||||
anchors: flowAnchors
|
||||
});
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
@@ -625,6 +711,56 @@ export default function MultipleOrders() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Where this run collects from. The hub stays selected either
|
||||
way — it is the servicing location the orders belong to, and
|
||||
the fallback for any row whose sender address will not
|
||||
geocode — but under 'customer' it stops being the address
|
||||
written onto every booking. */}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-2.5">
|
||||
<p className="text-[11px] font-semibold text-slate-700 mb-2">Collect from</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ mode: PICKUP_SOURCE.HUB, title: 'Business Hub', hint: 'One shared origin' },
|
||||
{ mode: PICKUP_SOURCE.CUSTOMER, title: 'Customer Pickup', hint: "Each row's sender address" }
|
||||
].map(({ mode, title, hint }) => {
|
||||
const active = pickupMode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
if (mode === pickupMode) return;
|
||||
setPickupMode(mode);
|
||||
// The drops already carry resolved origins, distances
|
||||
// and prices computed against the OLD mode. Keeping
|
||||
// them would show a priced, ready-to-dispatch list
|
||||
// that silently disagrees with the switch just made.
|
||||
setDropCust([]);
|
||||
}}
|
||||
className={`rounded-lg border px-2.5 py-2 text-left transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'border-black bg-white shadow-2xs ring-2 ring-black/10'
|
||||
: 'border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className={`block text-xs font-bold ${active ? 'text-slate-900' : 'text-slate-600'}`}>
|
||||
{title}
|
||||
</span>
|
||||
<span className="block text-[10px] text-slate-500 leading-tight mt-0.5">{hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isCustomerPickup && (
|
||||
<p className="mt-2 text-[10px] text-violet-700 bg-violet-50 border border-violet-200 rounded-lg px-2.5 py-1.5 leading-relaxed">
|
||||
Each row is collected from its <strong>Sender Address</strong> column. Rows without a usable
|
||||
one fall back to the hub above, and you will be told how many.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-800 mb-1.5">Pickup Date</label>
|
||||
@@ -661,6 +797,13 @@ export default function MultipleOrders() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold truncate">{pickCust.locationname || 'Pickup Hub'}</div>
|
||||
<div className="text-[11px] text-sky-800 line-clamp-1 mt-0.5">{pickCust.address || '—'}</div>
|
||||
{/* This tile reads as "everything is collected here", so it
|
||||
has to say when that is no longer true. */}
|
||||
{isCustomerPickup && (
|
||||
<div className="text-[10px] font-semibold text-sky-700 mt-1">
|
||||
Servicing hub only — rows are collected from their own sender addresses
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -887,6 +1030,14 @@ export default function MultipleOrders() {
|
||||
<tr>
|
||||
<th className="py-3 pl-4 pr-2 w-8">#</th>
|
||||
<th className="py-3 px-3 w-40">Customer</th>
|
||||
{/* Only in customer-pickup mode, and deliberately BEFORE the
|
||||
delivery address: a geocoded sender line is the one thing
|
||||
on this screen the operator has not seen with their own
|
||||
eyes. A coarse "Edayarpalayam, Coimbatore" resolves to a
|
||||
landmark in that locality, not the kitchen, and a rider
|
||||
sent there arrives at the wrong door. Better caught here
|
||||
than after thirteen orders are live. */}
|
||||
{isCustomerPickup && <th className="py-3 px-3">Collect from</th>}
|
||||
<th className="py-3 px-3">Address</th>
|
||||
<th className="py-3 px-2 w-16 text-center">Qty</th>
|
||||
<th className="py-3 px-2 w-24 text-center">Cash (₹)</th>
|
||||
@@ -905,6 +1056,30 @@ export default function MultipleOrders() {
|
||||
<div className="font-bold text-slate-900 text-xs truncate max-w-[150px]">{row.firstname || '—'}</div>
|
||||
<div className="text-[11px] text-slate-600 font-mono font-medium mt-0.5">{row.contactno || '—'}</div>
|
||||
</td>
|
||||
{isCustomerPickup && (
|
||||
<td className="py-3 px-3 min-w-[180px]">
|
||||
{row.__pickup ? (
|
||||
<>
|
||||
<div
|
||||
className="truncate max-w-[180px] text-[11px] font-semibold text-violet-800"
|
||||
title={row.__pickup.address}
|
||||
>
|
||||
{row.__pickup.locationname || 'Customer Pickup'}
|
||||
</div>
|
||||
<div
|
||||
className="truncate max-w-[180px] text-[10px] text-slate-500 mt-0.5"
|
||||
title={row.__pickup.address}
|
||||
>
|
||||
{row.__pickup.address}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[10px] font-semibold text-amber-700" title="Sender address could not be resolved">
|
||||
Fell back to hub
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 px-3 min-w-[220px]">
|
||||
{needsAddress ? (
|
||||
<AddressAutocomplete
|
||||
|
||||
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
|
||||
import { useBulkCancelBookings, useCustomers, useRiderOptions } from '@/lib/doormileHooks';
|
||||
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
import { createAutomationDeliveries } from '@/api/doormile';
|
||||
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
|
||||
@@ -12,8 +12,11 @@ import { drainBookings } from '@/lib/assistant/scan';
|
||||
import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
|
||||
import { FLOW, classifyBooking } from '@/lib/orderFlow';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
import { dropSummary, isMultiDrop } from '@/lib/bookingDrops';
|
||||
import { BookingDestinationsDrawer } from './BookingDestinationsDrawer';
|
||||
|
||||
/**
|
||||
* Orders — every booking, grouped by where it sits in the operator's workflow.
|
||||
@@ -82,6 +85,9 @@ export default function Orders() {
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
/* The row whose drops are open. Holds the row itself, not just the id, so the
|
||||
drawer can title itself from `bookingno` without waiting for the fetch. */
|
||||
const [dropsRow, setDropsRow] = useState(null);
|
||||
|
||||
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
|
||||
|
||||
@@ -165,6 +171,12 @@ export default function Orders() {
|
||||
|
||||
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
|
||||
|
||||
/* Which end of each booking is a facility. This page reads RAW bookings
|
||||
rather than the deliveries feed, so it has to classify for itself — but
|
||||
against the same shared anchor list, so a row cannot read "customer
|
||||
pickup" here and "hub run" on Dispatch. */
|
||||
const { data: flowAnchors = [] } = useFlowAnchors((bookings || []).map((b) => b?.tenantid));
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(zoneBookings || [])
|
||||
@@ -315,17 +327,37 @@ export default function Orders() {
|
||||
customer?.firstname ||
|
||||
customer?.name ||
|
||||
(row.appcustomerid ? `Customer #${row.appcustomerid}` : 'Drop');
|
||||
/* "Pickup" alone reads as "collected from our hub", which is what it
|
||||
always meant. Once it can also mean a doorstep, the row has to say
|
||||
which — otherwise two very different journeys look identical in the
|
||||
list. Silent on an ordinary hub run, so the label only appears
|
||||
where it carries information. */
|
||||
const flow = classifyBooking(row, flowAnchors);
|
||||
const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||
<span className="font-medium text-ink-1">Pickup</span>
|
||||
<span className={isCustomerPickup ? 'font-medium text-violet-700' : 'font-medium text-ink-1'}>
|
||||
{isCustomerPickup ? 'Customer pickup' : 'Pickup'}
|
||||
</span>
|
||||
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
|
||||
</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
<span className="font-medium text-ink-2">{dropName}</span>
|
||||
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
|
||||
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
|
||||
</p>
|
||||
{/* Collapsed, a multi-drop row reads as ONE pickup request that
|
||||
created several shipments — never as one of them. The flat
|
||||
`deliveryaddress` it would otherwise show is destination 0
|
||||
mirrored by CreateCxBooking, so on a three-drop pickup it is
|
||||
Chennai standing in for all three. Open the row for the legs. */}
|
||||
{isMultiDrop(row) ? (
|
||||
<p className="truncate text-caption text-ink-3">
|
||||
<span className="font-medium text-violet-700">{dropSummary(row)}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
<span className="font-medium text-ink-2">{dropName}</span>
|
||||
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
|
||||
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -361,6 +393,21 @@ export default function Orders() {
|
||||
return <StatusBadge status="arrived" dot size="sm" />;
|
||||
}
|
||||
if (rawStatus === 'converted_to_consignment') {
|
||||
/* THE ONE THAT WOULD SILENTLY LIE.
|
||||
`pickupbookings.consignmentid` names the FIRST leg only — set
|
||||
that way at pickup-complete so single-drop screens keep working
|
||||
— and `consignmentstatus` is joined from it. On a three-drop
|
||||
pickup that is Chennai's status wearing the whole booking's
|
||||
name, and rendering it here would confidently report "Delivered"
|
||||
while two parcels are still in transit.
|
||||
|
||||
A multi-drop row therefore shows the PICKUP's own state, which
|
||||
is genuinely finished once the rider has collected. Per-leg
|
||||
status lives in the drawer, read from each destination's own
|
||||
stage. */
|
||||
if (isMultiDrop(row)) {
|
||||
return <StatusBadge status="picked" dot size="sm" />;
|
||||
}
|
||||
const cStatus = row.consignmentstatus ?? row.consignment_status;
|
||||
return <StatusBadge status={cStatus || 'picked'} dot size="sm" />;
|
||||
}
|
||||
@@ -476,6 +523,18 @@ export default function Orders() {
|
||||
{!isAllZones && (
|
||||
<span className="font-medium text-ink-2"> · Zone: {selectedZone.hubname}</span>
|
||||
)}
|
||||
{/* Search runs over the flat booking columns, and `deliveryaddress`
|
||||
is destination 0 mirrored by CreateCxBooking. Searching
|
||||
"Ernakulam" on a three-drop pickup whose first drop is Chennai
|
||||
therefore finds nothing. The list has no destination array to
|
||||
index — it carries only the counts, deliberately — so the honest
|
||||
fix is to say which drop is searched rather than to fail
|
||||
silently. */}
|
||||
{debouncedSearch && (
|
||||
<span className="text-ink-3">
|
||||
{' '}· search covers the order number, pickup, notes and the first drop only
|
||||
</span>
|
||||
)}
|
||||
{/* A capped scan means an order really can be missing from this tab.
|
||||
Say so — a silently short list is what sent operators hunting for
|
||||
an order they had just created. */}
|
||||
@@ -493,6 +552,12 @@ export default function Orders() {
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.bookingid}
|
||||
/* Opens the drops for ANY booking, not only multi-drop ones: the
|
||||
per-destination recipient, instructions and COD exist nowhere else
|
||||
in the console, and a row that is clickable only sometimes is worse
|
||||
than one that always is. The checkbox cell stops propagation, so
|
||||
this does not fight selection. */
|
||||
onRowClick={(row) => setDropsRow(row)}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
|
||||
@@ -630,6 +695,15 @@ export default function Orders() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<BookingDestinationsDrawer
|
||||
open={Boolean(dropsRow)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setDropsRow(null);
|
||||
}}
|
||||
bookingId={dropsRow?.bookingid}
|
||||
bookingNo={dropsRow?.bookingno || (dropsRow ? `#${dropsRow.bookingid}` : undefined)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
tests/fixtures/doormile-logistics-test.xlsx
vendored
Normal file
BIN
tests/fixtures/doormile-logistics-test.xlsx
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/jayanthi-kitchen-route.xls
vendored
Normal file
BIN
tests/fixtures/jayanthi-kitchen-route.xls
vendored
Normal file
Binary file not shown.
459
tests/integration/bulkUpload.test.js
Normal file
459
tests/integration/bulkUpload.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
298
tests/integration/logisticsBulkUpload.test.js
Normal file
298
tests/integration/logisticsBulkUpload.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
119
tests/lib/DataTable.test.jsx
Normal file
119
tests/lib/DataTable.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
81
tests/lib/bookingDrops.test.js
Normal file
81
tests/lib/bookingDrops.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
173
tests/lib/customerAppBookings.test.js
Normal file
173
tests/lib/customerAppBookings.test.js
Normal 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
134
tests/lib/hubForm.test.js
Normal 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
480
tests/lib/orderFlow.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
tests/lib/routingSummary.test.js
Normal file
180
tests/lib/routingSummary.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user