updates on bugs on the dispatch and ai button integrations

This commit is contained in:
2026-09-24 11:50:04 +05:30
parent 5eb4a6466b
commit cbce47f949
18 changed files with 1190 additions and 124 deletions

View File

@@ -3,6 +3,7 @@ import { OpenToast } from './notify';
import logger from '@/lib/logger';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { buildAnchors, classifyBooking } from '@/lib/orderFlow';
import { makeLocationFilter } from '@/lib/locationScope';
import {
getMilers,
getMiler,
@@ -789,7 +790,13 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// parameter is documented in express-console-api.md and guessing one risks a
// silent 400 or, worse, a silently-ignored filter), so the range is applied
// client-side below, after the rows are normalised.
const [, , , , startdate, enddate, rowsPerPage] = queryKey;
//
// `appLocationId` is slot 1 and had the same fate startdate/enddate did: read
// out of the key and then ignored, so Control X's hub picker rebuilt the
// query, refetched, and returned the same nationwide rows. The chip read
// "Coimbatore Neptune Hub" over a map of Bangalore. It is applied below,
// client-side, for the same reason the dates are.
const [, appLocationId, , , startdate, enddate, rowsPerPage] = queryKey;
// Unlike the 3 joins below (customers/milers/tenants — each individually
// guarded so a failed join just degrades a display field, not the whole
// page), a failed bookings call is the one thing this function can't
@@ -812,11 +819,30 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// Guarded like the other three joins: if the call fails, or the response
// carries no recognisable status, the row falls back to the booking's status
// and behaviour is exactly what it was before this join existed.
const [customers, milers, tenants, consignments] = await Promise.all([
const [customers, milers, tenants, consignments, hubs] = await Promise.all([
getAdminCustomers().catch(() => []),
getMilers().catch(() => []),
getAdminTenants().catch(() => []),
getConsignments().catch(() => [])
getConsignments().catch(() => []),
// Only when a city is actually selected.
//
// This runs on every page of every fetch, and Dispatch polls on an 8s
// interval while auto-paging through the whole day — so an unconditional
// hub call would add a request per page per tick, on a list that four
// pages read and three of them never scope. `appLocationId` is falsy for
// "All locations" and for every caller outside Control X, and those skip it.
//
// Guarded like the rest: with no hub list there is no city footprint to
// test against, and makeLocationFilter answers "everything" — the old
// behaviour — rather than an empty board. Wrapped rather than `.catch()`ed
// directly because a caller handing back a non-promise would take the whole
// Promise.all down with a TypeError, turning a missing hub list into a
// blank board — the failure this filter exists to avoid.
Number(appLocationId)
? Promise.resolve()
.then(() => getHubs())
.catch(() => [])
: Promise.resolve([])
]);
// The id field on a consignment record has never been captured, so both
// plausible names are indexed rather than guessing one.
@@ -1060,8 +1086,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
return true;
};
// Null when no city is picked, so "All locations" skips the pass outright
// instead of running a filter that is always true.
const inLocation = makeLocationFilter(hubs, appLocationId);
return {
rows: rows.filter(inRange),
rows: inLocation ? rows.filter(inRange).filter(inLocation) : rows.filter(inRange),
nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};

View File

@@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import { PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import doormileMark from '@/assets/images/doormile-mark.png';
// ==============================|| Doormile AI — the launcher ||============================== //
//
// The floating control that opens MileTruth. It replaces the 32px icon that sat
// in the header between the search box and the notification bell.
//
// The header is a poor home for it. Everything else in that row is a utility
// the operator reaches for occasionally — search, alerts, the account menu —
// and the assistant was a bare brand mark among them, with nothing saying what
// it opened. Down here it is a named control that reads as one thing: a pill
// with the mark, the word, and the panel glyph that shows where it will appear.
//
// It is NOT rendered on the home page, where the assistant is the page rather
// than a panel beside it. That rule lives with the caller, next to the matching
// rule for the panel itself, so the two cannot disagree about where it applies.
const AILauncher = ({ isOpen, onToggle, className }) => {
// Hidden while the panel is open.
//
// The panel docks to the right edge and this button is pinned to the bottom
// right, so an open panel sits underneath it on a wide screen and covers it
// completely on a narrow one, where the panel goes full-width behind a scrim.
// Rather than chase the dock width across breakpoints, the launcher steps
// aside: the panel's own header carries the close control, so nothing is lost.
if (isOpen) return null;
return (
<button
type="button"
onClick={onToggle}
aria-label="Open MileTruth"
aria-expanded={false}
title="MileTruth — Operations Copilot"
className={cn(
// Sits under the panel's own layer (1200) so a panel opening over it is
// never a z-index race, and above ordinary page chrome.
'fixed bottom-5 right-5 z-[1100]',
'inline-flex items-center gap-2 rounded-full',
'border border-border bg-surface py-1.5 pl-1.5 pr-3',
'shadow-lg shadow-black/10',
'cursor-pointer transition-all duration-150',
'hover:-translate-y-0.5 hover:shadow-xl',
// The outline is the brand crimson, on hover AND on keyboard focus.
//
// `ring-brand` at full strength, not the `/20` wash the header icon
// used: on a white pill a 20% tint of #C8102E reads as a pale pink
// smudge rather than as an outline, which is the whole affordance.
'hover:border-brand/40 hover:ring-2 hover:ring-brand',
'focus-visible:outline-none focus-visible:border-brand/40 focus-visible:ring-2 focus-visible:ring-brand',
className
)}
>
<img
src={doormileMark}
alt=""
aria-hidden="true"
// `object-contain` and no `max-w-none`, so the mark can never outgrow
// its slot however the asset is redrawn — the bug the header icon hit
// twice before it settled.
className="h-7 w-7 shrink-0 object-contain"
/>
<span className="text-body-sm font-bold text-ink-1">Doormile</span>
<PanelRight className="h-4 w-4 shrink-0 text-ink-4" aria-hidden="true" />
</button>
);
};
AILauncher.propTypes = {
isOpen: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
className: PropTypes.string,
};
export default AILauncher;

View File

@@ -18,8 +18,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
import { useAuth } from '@/lib/AuthContext';
import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import doormileMark from 'assets/images/doormile-mark.png';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
/**
* The console shell.
@@ -387,58 +387,10 @@ export default function AdminLayout() {
<div className="ml-auto flex items-center gap-1.5">
<GlobalSearch />
{/* MileTruth. A header control rather than a route: shown on all pages except the home page */}
{!isHome && (
<button
type="button"
onClick={() => toggleAssistant((open) => !open)}
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
aria-expanded={assistantOpen}
className={cn(
'grid h-8 w-8 place-items-center rounded-lg transition-all cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
// A RING, not a background fill.
//
// The fill was what capped the mark's size: it renders
// behind the image, so a larger mark covered it and the
// control looked dead on hover. That is why the icon sat at
// 20px, then 24px — each bump ate the only affordance the
// button had.
//
// A ring draws at the button's edge instead, so it stays
// visible whatever the mark's size, and the icon is free to
// be as large as the 32px box allows. The tint is kept
// underneath for the open state, where it still shows
// around the mark's transparent corners.
assistantOpen
? 'bg-brand-tint text-brand ring-2 ring-brand/30'
: 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1 hover:ring-2 hover:ring-brand/20'
)}
>
{/* 28px, filling the 32px button.
The size history is worth keeping, because each step was
forced by the hover treatment rather than by taste. It
began at `h-9 w-9 max-w-none` — 36px inside a 32px button,
overflowing its own control by 2px on every side, which
broke the row's alignment against the bell and avatar and
covered the button so the hover fill had nowhere to show.
20px fixed both and read as too faint. 24px was as far as
it could go while a background fill was still the
affordance.
Moving that affordance to a ring (see the button above)
removed the cap, so this is sized for legibility rather
than around a constraint: 28px in a 32px box, and
comfortably larger than the Bell's 16px — which is right,
because a brand mark carrying its own circular frame reads
smaller than its box where a stroked icon reads full-size.
`object-contain` with no `max-w-none`, so it can never
overflow the button again however the asset changes. */}
<img className="h-7 w-7 object-contain" src={doormileMark} alt="" aria-hidden="true" />
</button>
)}
{/* MileTruth's control is no longer here. It is the floating
launcher at the bottom of the page (AILauncher, mounted beside
the panel below), so this row holds only the utilities that
belong in a header: search, alerts, the account menu. */}
{!isClient && <NotificationsMenu />}
@@ -562,7 +514,16 @@ export default function AdminLayout() {
</main>
</div>
{!isHome && <AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />}
{/* Both guarded by the SAME condition, deliberately adjacent: the home
page runs the assistant as the page itself, so neither the docked
panel nor its launcher belongs there. Splitting these two rules apart
is how you end up with a button that opens nothing. */}
{!isHome && (
<>
<AILauncher isOpen={assistantOpen} onToggle={() => toggleAssistant((open) => !open)} />
<AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />
</>
)}
</div>
);
}

View File

@@ -1,6 +1,9 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
import { useAuth } from '@/lib/AuthContext';
// Shared with Control X's city filter. Two radii would mean the Orders page and
// the dispatch board disagreeing about which city an order is in.
import { haversineKm, ZONE_RADIUS_KM } from '@/lib/locationScope';
const ZONE_STORAGE_KEY = 'doormile_active_zone_id';
@@ -13,17 +16,6 @@ const ALL_ZONE = Object.freeze({
status: 'Active',
});
const haversineKm = (lat1, lon1, lat2, lon2) => {
if (![lat1, lon1, lat2, lon2].every((v) => Number.isFinite(Number(v)))) return undefined;
const R = 6371;
const toRad = (deg) => (Number(deg) * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const ZoneContext = createContext(null);
export function ZoneProvider({ children }) {
@@ -147,21 +139,40 @@ 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 ??
item.tenantlocationid ??
item.locationid;
if (itemHubId != null && String(itemHubId) === targetHubId) {
// The hub ids a booking can actually carry. `pickuphubid` and
// `nearesthubid` are the only hub foreign keys that exist on the
// pickupbookings row; `servicinghubid` is synthesised by queries.js and so
// reaches this function on Deliveries but never on Orders; `hubid`,
// `sourcehubid`, `applocationid` and `locationid` are not columns on a
// booking at all and are kept only for the other row shapes that pass
// through here.
//
// Until `pickuphubid` was added, every one of those was undefined on the
// Orders page, so this whole branch fell through and the 35km radius below
// was the only matcher left running — which means an order that reached the
// database without coordinates disappeared from every zone at once, with
// nowhere to see it and nothing to say it had been hidden.
//
// Each branch compares ONE id space against itself, and this is the whole
// reason the list is split.
//
// A staff zone is a hub, so only hub ids may be tested against it.
// `applocationid` is a city, `tenantlocationid` and `locationid` are a
// client's own sites — different numbering entirely, and a delivery row
// carries several of them at once (queries.js sets servicinghubid,
// applocationid and tenantlocationid on the same row). Booking DM-626241 is
// the live example: servicinghubid 5 (Bangalore Earth Hub) alongside
// tenantlocationid 3, which as a hub id is Hyderabad Mars Hub. Testing them
// all would file a Bangalore run under Hyderabad on a bare numeric
// collision.
//
// The old `??` chain hid this by stopping at the first non-null field, so
// the collision only fired when the earlier ones happened to be absent. It
// was a bug waiting on its inputs, not a safe design.
const itemHubIds = isTenantUser
? [item.tenantlocationid, item.locationid]
: [item.servicinghubid, item.pickuphubid, item.nearesthubid, item.hubid, item.sourcehubid];
if (itemHubIds.some((id) => id != null && String(id) === targetHubId)) {
return true;
}
@@ -184,7 +195,7 @@ export function ZoneProvider({ children }) {
return true;
}
// 3. Coordinate distance match (within 35km radius of hub)
// 3. Coordinate distance match, within ZONE_RADIUS_KM of the hub
const hubLat = Number(currentHub.latitude);
const hubLon = Number(currentHub.longitude);
if (Number.isFinite(hubLat) && Number.isFinite(hubLon)) {
@@ -196,8 +207,8 @@ export function ZoneProvider({ children }) {
const pickDist = haversineKm(hubLat, hubLon, pickLat, pickLon);
const dropDist = haversineKm(hubLat, hubLon, dropLat, dropLon);
if (pickDist !== undefined && pickDist <= 35) return true;
if (dropDist !== undefined && dropDist <= 35) return true;
if (pickDist !== undefined && pickDist <= ZONE_RADIUS_KM) return true;
if (dropDist !== undefined && dropDist <= ZONE_RADIUS_KM) return true;
}
return false;

View File

@@ -20,3 +20,32 @@ export const toDateRange = (value) => {
if (typeof value !== 'object') return { from: '', to: '' };
return { from: value.from || '', to: value.to || '' };
};
/**
* Keep the rows whose `createdat` falls inside the DatePicker's current value.
*
* Lives here rather than inline on each page because Orders and Bookings read
* the same `/admin/bookings` rows through the same picker, and a page that
* wrote its own comparison would eventually disagree with the other about what
* "today" contains — the kind of difference nobody notices until two screens
* show different counts for the same day.
*
* A row whose timestamp will not parse is dropped from a date-scoped view. That
* is the same call `fetchBookingsInRange` makes: a date filter cannot honestly
* claim a row it could not place in time. Nothing is dropped when the value is
* 'all', which is the case that has to keep showing everything.
*/
export const filterByCreatedAt = (rows, value, parse) => {
if (!value || value === 'all') return rows || [];
const { from, to } = toDateRange(value);
if (!from && !to) return rows || [];
return (rows || []).filter((row) => {
const at = parse(row?.createdat);
if (!at?.isValid?.()) return false;
const day = at.format('YYYY-MM-DD');
if (from && day < from) return false;
if (to && day > to) return false;
return true;
});
};

106
src/lib/locationScope.js Normal file
View File

@@ -0,0 +1,106 @@
// ==============================|| Which city a row belongs to ||============================== //
//
// Control X carries a hub/city picker in its header, keyed on `applocationid`.
// It rebuilt the query key and refetched, and then filtered nothing: the id was
// destructured out of the key in `fetchDeliveries` and never read, exactly as
// startdate/enddate once were. The chip said "Coimbatore" while the map drew a
// Bangalore rider on a Bangalore road, which is worse than having no picker —
// an operator reads a labelled screen as scoped.
//
// `/admin/bookings` takes only pageno/pagesize, so this is a client-side pass,
// the same shape the date range uses.
/** How far from a hub a stop can be and still belong to that hub's city. */
export const ZONE_RADIUS_KM = 35;
export const haversineKm = (lat1, lon1, lat2, lon2) => {
if (![lat1, lon1, lat2, lon2].every((v) => Number.isFinite(Number(v)))) return undefined;
const R = 6371;
const toRad = (deg) => (Number(deg) * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
/** A coordinate pair is only usable if it is real. 0,0 is the Gulf of Guinea,
* and it is what this system writes when nothing geocoded — both for a hub
* (`Coimbatore Test Hub`) and for an order saved without a pin. Treating it as
* a place puts orders in the sea; treating it as absent is the truth. */
const usable = (lat, lon) =>
Number.isFinite(Number(lat)) &&
Number.isFinite(Number(lon)) &&
!(Number(lat) === 0 && Number(lon) === 0);
const hasCoords = (h) => usable(h?.latitude, h?.longitude);
/** Every hub id a row might name. */
const NAMED_HUB_KEYS = ['servicinghubid', 'pickuphubid', 'nearesthubid', 'hubid', 'sourcehubid'];
/**
* A predicate for "this row belongs to the selected city", or `null` for
* "everything" — which is what the caller wants when no city is selected.
*
* Returns null rather than a match-all function so the caller can skip the pass
* entirely, and so "All locations" is one branch rather than a filter that
* happens to be true.
*
* An unrecognised `applocationId`, or one with no hubs, also returns null. A
* picker pointing at a city we cannot resolve must not empty the board: a blank
* screen reads as "no work today", and that is the more expensive mistake.
*/
export const makeLocationFilter = (hubs, applocationId) => {
const id = Number(applocationId);
if (!Number.isFinite(id) || id === 0) return null;
const all = hubs || [];
const here = all.filter((h) => Number(h?.applocationid) === id);
if (!here.length) return null;
const hubIds = new Set(here.map((h) => String(h.hubid)));
// Every known hub, so a row naming one somewhere ELSE counts as located —
// and is therefore excluded here rather than treated as unplaceable.
const knownHubIds = new Set(all.map((h) => String(h?.hubid)));
const centres = here.filter(hasCoords);
return (row) => {
if (!row) return false;
const named = NAMED_HUB_KEYS.map((k) => row[k]).filter((v) => v != null).map(String);
// 1. The hub the row itself names, when that hub is one of ours.
if (named.some((v) => hubIds.has(v))) return true;
// 2. Either end of the journey inside the city's footprint. A run that
// collects here and delivers elsewhere is still this city's work, and so
// is one that merely arrives — so either end qualifying is enough.
const ends = [
[row.pickuplatitude ?? row.latitude, row.pickuplongitude ?? row.longitude],
[row.deliverylatitude ?? row.droplat, row.deliverylongitude ?? row.droplon],
];
let located = false;
for (const [lat, lon] of ends) {
if (!usable(lat, lon)) continue;
for (const hub of centres) {
const d = haversineKm(hub.latitude, hub.longitude, lat, lon);
if (d === undefined) continue;
located = true;
if (d <= ZONE_RADIUS_KM) return true;
}
}
// 3. It names a hub we know, and that hub is not in this city.
if (named.some((v) => knownHubIds.has(v))) return false;
// 4. Its coordinates put it somewhere, and somewhere else. Drop it — that
// is the whole point of the picker.
if (located) return false;
// 5. Not locatable at all: no hub we recognise, no usable coordinates. Keep
// it. A row that cannot be placed has to stay visible under every city
// rather than disappear from all of them with nothing to say it was
// hidden — the exact failure this area keeps producing.
return true;
};
};

View File

@@ -435,6 +435,12 @@ export const classifyBooking = (booking, anchors = []) => {
const servicingHubId =
b.sourcehubid ??
b.hubid ??
// The column the backend actually writes when the collection point is a
// base (`createExpressBooking` sets it from the request, and the hub
// console reads it back). It outranks the anchor match below because it is
// what the order RECORDS, where the anchor is only what its coordinates
// resolve to — and those two can disagree once a hub is moved or retired.
b.pickuphubid ??
(originAnchor?.kind === 'hub' ? originAnchor.hubid : null) ??
originAnchor?.hubid ??
(destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ??

View File

@@ -1,13 +1,14 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import {
Ban, Bike,
Ban, Bike, FileSpreadsheet,
Package, PackageSearch, RefreshCw,
} from 'lucide-react';
import {
Alert, Button, DataTable, Drawer, EmptyState, Grid, KpiCard,
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Grid, KpiCard,
Modal, Stack, StatusBadge, Surface, Tabs,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ds';
@@ -23,7 +24,8 @@ import {
quotedPrice, serviceOptionOf, serviceType,
} from '@/lib/customerAppBookings';
import { currency, exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { filterByCreatedAt } from '@/lib/dateRange';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
/**
@@ -223,7 +225,14 @@ function BookingDrawer({ bookingId, onClose }) {
}
export default function Bookings() {
const todayStr = dayjs().format('YYYY-MM-DD');
const [tab, setTab] = useState('all');
/* Opens on today. This page polls every 15s and is read as a live queue, so
the default has to be the day being worked, not the whole account — the
same default the Orders list uses, so the two agree about what "today"
shows. "All Dates" is one click away in the picker. */
const [selectedDate, setSelectedDate] = useState(todayStr);
const [search, setSearch] = useState('');
const [openId, setOpenId] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
@@ -244,7 +253,32 @@ export default function Bookings() {
refetchOnMount: 'always',
});
const bookings = useMemo(() => customerAppBookings(scan?.rows), [scan]);
const allBookings = useMemo(() => customerAppBookings(scan?.rows), [scan]);
/* Dated before counted. Counting the whole account and listing one day would
put "Created 12" above an empty table, which reads as a broken page rather
than as a filter doing its job. */
const bookings = useMemo(
() => filterByCreatedAt(allBookings, selectedDate, parseDoormileTimestamp),
[allBookings, selectedDate]
);
/* What the picker is currently showing, in words. Every number on this page
is now scoped to it, so the scope has to be visible next to the numbers —
a KPI reading "Customer bookings 0" is a lie if the account has three
hundred and the picker is on a quiet Tuesday. */
const dateLabel = useMemo(() => {
if (!selectedDate || selectedDate === 'all') return 'all time';
if (typeof selectedDate === 'string') {
return selectedDate === todayStr ? 'today' : selectedDate;
}
if (selectedDate.from && selectedDate.to) {
return selectedDate.from === selectedDate.to
? selectedDate.from
: `${selectedDate.from} → ${selectedDate.to}`;
}
return selectedDate.from ? `from ${selectedDate.from}` : 'all time';
}, [selectedDate, todayStr]);
// Tab counts
const counts = useMemo(() => {
@@ -270,10 +304,11 @@ export default function Bookings() {
[rows, selectedIds]
);
// Clear selection when tab changes
/* A row that left the current view must not stay silently selected — the
assign and cancel actions act on the selection, not on what is on screen. */
useEffect(() => {
setSelectedIds([]);
}, [tab]);
}, [tab, selectedDate]);
const stats = useMemo(() => {
const created = counts.created;
@@ -432,9 +467,9 @@ export default function Bookings() {
)}
<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)} />
<KpiCard label={`Customer bookings · ${dateLabel}`} value={stats.total} />
<KpiCard label={`Created (Awaiting Rider) · ${dateLabel}`} value={stats.created} />
<KpiCard label={`Total quoted · ${dateLabel}`} value={currency(stats.value)} />
</Grid>
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
@@ -456,14 +491,35 @@ export default function Bookings() {
search={search}
onSearchChange={setSearch}
placeholder="Search booking no, address or status"
onExport={() => exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}`)}
exportDisabled={rows.length === 0}
actions={
<Button variant="outline" onClick={() => refetch()} loading={isFetching}>
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
</Button>
<>
{/* Export lives in `actions` because that is the only slot
ListToolbar renders. It was being passed as `onExport` /
`exportDisabled`, which the component has never accepted, so
the button did not exist and the callback never ran. */}
<Button
variant="ghost"
onClick={() => {
/* Named for what it contains. A file called
`customer-bookings-all` holding a single day of rows is
the kind of thing that gets forwarded as though it were
the whole account. */
const dateTag =
typeof selectedDate === 'string' ? selectedDate : selectedDate?.from || 'all';
exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}-${dateTag}`);
}}
disabled={rows.length === 0}
>
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
</Button>
<Button variant="outline" onClick={() => refetch()} loading={isFetching}>
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
</Button>
</>
}
/>
>
<DatePicker value={selectedDate} onChange={setSelectedDate} allowAll />
</ListToolbar>
</div>
</div>
@@ -483,8 +539,17 @@ export default function Bookings() {
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."
title={`No ${BOOKING_TABS.find((entry) => entry.value === tab)?.label.toLowerCase()} bookings ${
selectedDate === 'all' ? '' : `for ${dateLabel}`
}`.trim()}
/* An empty table has two very different causes and they must not
read the same: nothing was booked, or a date is hiding what was.
The second one says so, and says where the switch is. */
description={
selectedDate === 'all'
? 'Customer app bookings will appear here as they are placed.'
: 'Nothing was booked in this period. Use the date picker to widen the range, or pick All Dates.'
}
/>
)
}

View File

@@ -86,7 +86,25 @@
to go below its content width, which is what forced the overflow onto
the items to its right. */
min-width: 0;
overflow: hidden;
/* NOT `overflow: hidden`.
It was, and it clipped both dropdowns anchored in this group — the hub
picker and the batch picker. Their menus are absolutely positioned inside
.logo-city-wrap / the batch wrapper, both of which sit inside this box, so
this box's overflow is what decides whether they are drawn. #hdr is 56px
tall and the menus open BELOW the trigger, so every pixel of them fell
outside and neither one was ever visible: the trigger highlighted, the
caret turned, and nothing appeared. z-index cannot rescue a clipped box.
`overflow-x: hidden` is not an alternative — CSS forces the other axis to
`auto` the moment one axis is not `visible`, which would clip vertically
again and add a scrollbar.
What this rule was actually for is keeping the group from pushing the
clock past the right gutter, and `min-width: 0` above is what does that.
The clipping that stops shrunk TEXT from spilling belongs on the text, so
.logo-name and .logo-city-text carry it. */
overflow: visible;
}
/* The badge is sized to the D ITSELF, not to the image file.
@@ -120,6 +138,20 @@
font-weight: 800;
color: var(--text);
letter-spacing: -0.02em;
/* The clipping that used to live on .logo, moved to the one child that can
actually grow unbounded. Ellipsising the title is survivable; swallowing a
dropdown was not. */
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The tab group holds the batch dropdown, so it must not clip either. It still
has to be allowed to shrink, which is min-width, not overflow. */
.dispatch-container #dispatch-top-tabs {
min-width: 0;
overflow: visible;
}
.dispatch-container .logo-name em {

View File

@@ -12,6 +12,7 @@ import { OpenToast, messageOf } from '@/api/doormile/notify';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
import { FLOW, classifyBooking } from '@/lib/orderFlow';
import { filterByCreatedAt } from '@/lib/dateRange';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { dropSummary, isMultiDrop } from '@/lib/bookingDrops';
@@ -122,24 +123,14 @@ export default function Orders() {
});
}, [bookings]);
const dateFilteredBookings = useMemo(() => {
if (!selectedDate || selectedDate === 'all') return sortedBookings;
return sortedBookings.filter((booking) => {
const at = parseDoormileTimestamp(booking.createdat);
if (!at.isValid()) return false;
if (typeof selectedDate === 'string') {
return at.format('YYYY-MM-DD') === selectedDate;
}
if (selectedDate.from && selectedDate.to) {
const day = at.format('YYYY-MM-DD');
return day >= selectedDate.from && day <= selectedDate.to;
}
if (selectedDate.from) {
return at.format('YYYY-MM-DD') >= selectedDate.from;
}
return true;
});
}, [sortedBookings, selectedDate]);
/* Shared with the Bookings list, which reads the same rows through the same
picker. Two copies of this comparison would eventually disagree about what
a given day contains, and the two screens would quietly show different
counts for it. */
const dateFilteredBookings = useMemo(
() => filterByCreatedAt(sortedBookings, selectedDate, parseDoormileTimestamp),
[sortedBookings, selectedDate]
);
const zoneBookings = useMemo(() => {
return dateFilteredBookings.filter(matchesZone);

View File

@@ -71,11 +71,77 @@ const withNoJoins = () => {
endpoints.getMilers.mockResolvedValue([]);
endpoints.getAdminTenants.mockResolvedValue([]);
endpoints.getConsignments.mockResolvedValue([]);
// The hub list backs the city filter. Empty here on purpose: `key()` passes
// applocationid 1, and with no hubs to resolve it against the filter has to
// fail open, leaving every assertion below about status/date/joins alone.
endpoints.getHubs.mockResolvedValue([]);
};
describe('deliveries data layer', () => {
beforeEach(withNoJoins);
/**
* The hub picker on Control X.
*
* `applocationid` is slot 1 of the query key, and it was destructured out and
* then never read — the same silent no-op this folder's CLAUDE.md records for
* startdate/enddate, which it survived. Picking a hub rebuilt the key,
* refetched, and returned the identical nationwide row set, so the header
* chip read "Coimbatore Neptune Hub" while the map drew a Bangalore rider on
* Mysore Road.
*
* These assert the KEY POSITION is consumed, not just that the helper works —
* locationScope.test.js covers the matching rules. A filter nothing calls is
* exactly what went wrong here twice.
*/
describe('fetchDeliveries — the city in the query key', () => {
const HUBS = [
{ hubid: 1, hubname: 'Coimbatore Jupiter Hub', applocationid: 1, latitude: 11.0168, longitude: 76.9558 },
{ hubid: 5, hubname: 'Bangalore Earth Hub', applocationid: 3, latitude: 12.9352, longitude: 77.6244 },
];
const cityKey = (appId) => ['dispatchDeliveries', appId, null, 'all', undefined, undefined, 100];
const coimbatore = booking({ bookingid: 1, bookingno: 'DM-CBE',
pickuplatitude: 11.0168, pickuplongitude: 76.9558,
deliverylatitude: 11.0183, deliverylongitude: 76.9680 });
const bangalore = booking({ bookingid: 2, bookingno: 'DM-BLR',
pickuplatitude: 12.9352, pickuplongitude: 77.6244,
deliverylatitude: 12.9439, deliverylongitude: 77.5092 });
beforeEach(() => {
endpoints.getHubs.mockResolvedValue(HUBS);
endpoints.getBookings.mockResolvedValue([coimbatore, bangalore]);
});
it('Coimbatore returns the Coimbatore run and not the Bangalore one', async () => {
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(1) });
expect(rows.map((r) => r.bookingno)).toEqual(['DM-CBE']);
});
it('Bangalore returns the Bangalore run and not the Coimbatore one', async () => {
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(3) });
expect(rows.map((r) => r.bookingno)).toEqual(['DM-BLR']);
});
it('All locations returns both', async () => {
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(0) });
expect(rows.map((r) => r.bookingno).sort()).toEqual(['DM-BLR', 'DM-CBE']);
});
it('a failed hub list shows everything rather than an empty board', async () => {
// A blank dispatch board reads as "no work today" and is acted on. It
// must never be what a failed side-call produces.
endpoints.getHubs.mockRejectedValue(new Error('hubs down'));
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(1) });
expect(rows).toHaveLength(2);
});
it('an unknown city shows everything rather than an empty board', async () => {
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(999) });
expect(rows).toHaveLength(2);
});
});
describe('fetchDeliveries — dispatched filtering', () => {
it('should surface a booking that already has a rider assigned', async () => {
endpoints.getBookings.mockResolvedValue([booking()]);
@@ -623,3 +689,29 @@ describe('deliveries data layer', () => {
});
});
});
describe('fetchDeliveries — the hub list is only fetched when it is needed', () => {
// Dispatch polls every 8 seconds and auto-pages through the whole day, so an
// unconditional call here is a request per page per tick on a list that four
// pages read and three never scope.
beforeEach(() => {
endpoints.getBookings.mockResolvedValue([]);
endpoints.getHubs.mockResolvedValue([]);
});
it('is not fetched for All locations', async () => {
await fetchDeliveries({ pageParam: 1, queryKey: ['d', 0, null, 'all', undefined, undefined, 100] });
expect(endpoints.getHubs).not.toHaveBeenCalled();
});
it('is not fetched by a caller that passes no city at all', async () => {
// reports/OperationalReportCards passes '' in this slot.
await fetchDeliveries({ pageParam: 1, queryKey: ['d', '', '', '', '2026-09-01', '2026-09-30', 500] });
expect(endpoints.getHubs).not.toHaveBeenCalled();
});
it('is fetched once a city is selected', async () => {
await fetchDeliveries({ pageParam: 1, queryKey: ['d', 3, null, 'all', undefined, undefined, 100] });
expect(endpoints.getHubs).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
jest.mock('@/assets/images/doormile-mark.png', () => 'doormile-mark.png', { virtual: true });
jest.mock('lucide-react', () =>
new Proxy({}, {
get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />),
})
);
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
/**
* The floating control that opens MileTruth, in place of the bare 32px brand
* mark that used to sit in the header between search and the bell.
*
* Two things here are requirements rather than styling, and both are the kind
* that a refactor silently undoes: the outline is the brand crimson on hover
* and on keyboard focus, and the button steps aside while the panel is open
* instead of sitting on top of a docked panel.
*/
const setup = (props = {}) => {
const onToggle = jest.fn();
const utils = render(<AILauncher isOpen={false} onToggle={onToggle} {...props} />);
return { ...utils, onToggle };
};
describe('the MileTruth launcher', () => {
it('is named, not just a mark', () => {
// The header version was an unlabelled logo among utility icons, with
// nothing on screen saying what it opened.
setup();
expect(screen.getByText('Doormile')).toBeInTheDocument();
});
it('opens the assistant when clicked', () => {
const { onToggle } = setup();
fireEvent.click(screen.getByRole('button', { name: /open miletruth/i }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('outlines in brand red on hover, not the default focus blue', () => {
// `ring-brand` is #C8102E. A `/20` wash — what the header icon used — reads
// as a pale smudge on a white pill rather than as an outline.
const { container } = setup();
const cls = container.querySelector('button').className;
expect(cls).toMatch(/hover:ring-brand(?!\/)/);
expect(cls).toMatch(/hover:ring-2/);
});
it('outlines the same way for keyboard focus', () => {
const { container } = setup();
const cls = container.querySelector('button').className;
expect(cls).toMatch(/focus-visible:ring-brand(?!\/)/);
// and never falls back to the user agent's own outline, which is the blue
// ring this replaces.
expect(cls).toMatch(/focus-visible:outline-none/);
});
it('floats bottom-right, below the panel layer', () => {
// The panel is z-index 1200 and its scrim 1300. A launcher above either
// would sit on top of an open panel.
const { container } = setup();
const cls = container.querySelector('button').className;
expect(cls).toMatch(/fixed/);
expect(cls).toMatch(/bottom-5/);
expect(cls).toMatch(/right-5/);
expect(cls).toMatch(/z-\[1100\]/);
});
it('gets out of the way while the panel is open', () => {
// The panel docks to the right on a wide screen and covers the page behind
// a scrim on a narrow one. Either way this button would be underneath it.
const { container } = render(<AILauncher isOpen onToggle={jest.fn()} />);
expect(container.querySelector('button')).toBeNull();
});
it('the mark can never outgrow its slot', () => {
// The header icon hit this twice: a 36px image inside a 32px button,
// overflowing on every side and breaking the row's alignment.
const { container } = setup();
const img = container.querySelector('img');
expect(img.className).toMatch(/object-contain/);
expect(img.className).not.toMatch(/max-w-none/);
expect(img).toHaveAttribute('aria-hidden', 'true');
});
});

View File

@@ -0,0 +1,70 @@
import fs from 'fs';
import path from 'path';
/**
* The Control X header must not clip its own dropdowns.
*
* `.logo` holds the page title, the hub picker and the batch picker. Both
* pickers render their menu as an absolutely-positioned child of a
* `position: relative` wrapper that sits INSIDE `.logo`, so `.logo` is what
* decides whether the menu is drawn at all. `#hdr` is 56px tall and the menus
* open below their trigger, so with `overflow: hidden` on `.logo` every pixel
* of them fell outside the box: the trigger highlighted, the caret rotated, and
* nothing appeared. The batch menu's `z-index: 9999` could not save it — z-index
* does not escape a clipping ancestor.
*
* Asserted against the stylesheet because this cannot be caught in jsdom, which
* does not lay out or clip anything, and the page it belongs to is a
* ~5,800-line Leaflet component that no unit test mounts.
*/
const css = fs.readFileSync(
path.join(__dirname, '../../src/pages/doormile/dispatch/Dispatch.css'),
'utf8'
);
/** The declarations inside one selector's first rule block. */
const ruleFor = (selector) => {
const at = css.indexOf(selector + ' {');
if (at === -1) throw new Error(`no rule for ${selector}`);
return css.slice(at, css.indexOf('}', at));
};
const overflowIn = (block) => {
const m = [...block.matchAll(/(?:^|\n)\s*overflow\s*:\s*([^;]+);/g)];
return m.length ? m[m.length - 1][1].trim() : null;
};
describe('the Control X header', () => {
it('does not clip the group that contains the dropdowns', () => {
expect(overflowIn(ruleFor('.dispatch-container .logo'))).not.toBe('hidden');
});
it('does not clip the tab group, which holds the batch menu', () => {
expect(overflowIn(ruleFor('.dispatch-container #dispatch-top-tabs'))).not.toBe('hidden');
});
it('still keeps the group shrinkable, which is what min-width does', () => {
// Removing the overflow must not bring back the overflowing header row
// that pushed the clock past the right gutter. `min-width: 0` is the rule
// that actually lets a flex item shrink below its content width.
expect(ruleFor('.dispatch-container .logo')).toMatch(/min-width:\s*0/);
});
it('clips the title instead, so a narrow header still cannot spill', () => {
const title = ruleFor('.dispatch-container .logo-name');
expect(title).toMatch(/overflow:\s*hidden/);
expect(title).toMatch(/text-overflow:\s*ellipsis/);
});
it('leaves the badge clipping its own transparent padding', () => {
// .logo-badge scales its image 150% to cancel the PNG's padding and relies
// on its OWN overflow to crop it. That is unrelated to the dropdowns.
expect(ruleFor('.dispatch-container .logo-badge')).toMatch(/overflow:\s*hidden/);
});
it('the menu is still positioned under its trigger', () => {
const menu = ruleFor('.dispatch-container .logo-city-menu');
expect(menu).toMatch(/position:\s*absolute/);
expect(menu).toMatch(/top:\s*calc\(100% \+ 6px\)/);
});
});

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import dayjs from 'dayjs';
jest.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }) => <div>{children}</div>,
DropdownMenuContent: ({ children }) => <div>{children}</div>,
DropdownMenuItem: ({ children, onClick }) => <button type="button" onClick={onClick}>{children}</button>,
DropdownMenuLabel: ({ children }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
}));
jest.mock('lucide-react', () =>
new Proxy({}, {
get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />),
})
);
const today = dayjs().format('YYYY-MM-DD');
const lastWeek = dayjs().subtract(7, 'day').format('YYYY-MM-DD');
// Two customer-app bookings: one placed today, one a week ago.
jest.mock('@/lib/assistant/scan', () => ({
__esModule: true,
drainBookings: jest.fn(),
}));
jest.mock('@/lib/doormileHooks', () => ({
__esModule: true,
useBooking: () => ({ data: null, isLoading: false, refetch: jest.fn() }),
useAssignMilerToBooking: () => ({ mutateAsync: jest.fn(), isPending: false }),
useRiderOptions: () => ({ data: [] }),
}));
import Bookings from '@/pages/doormile/bookings/Bookings';
import { drainBookings } from '@/lib/assistant/scan';
const booking = (bookingno, createdat) => ({
bookingid: bookingno, bookingno, createdat,
status: 'Pending_Pickup', bookingsource: 'Customer_App',
pickupaddress: 'Somewhere', pickuppincode: '641001',
deliveryaddress: 'Elsewhere', deliverypincode: '641002',
});
const renderPage = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<Bookings />
</QueryClientProvider>
);
};
/**
* The Bookings list had no date control at all: it drained every page of
* `/admin/bookings` and showed the lot, so the newest booking sat wherever the
* scan happened to put it and there was no way to ask "what came in today".
*
* The picker defaults to today, and every number on the page is scoped to it —
* which is the part worth pinning. A KPI that keeps counting the whole account
* while the table shows one day reads as a broken page, not as a filter.
*/
describe('the Bookings date picker', () => {
beforeEach(() => {
drainBookings.mockResolvedValue({
rows: [booking('DM-TODAY', `${today} 09:00:00`), booking('DM-OLD', `${lastWeek} 09:00:00`)],
truncated: false, scanned: 2, pagesFetched: 1, total: 2,
});
});
it('renders a date control, defaulted to today', async () => {
const { container } = renderPage();
await waitFor(() => expect(drainBookings).toHaveBeenCalled());
// The trigger carries the calendar icon and labels itself with the active
// range. Asserted on the container's text rather than a single node because
// the label and its "Today" badge are two sibling text nodes.
expect(container.querySelector('[data-testid="icon-Calendar"]')).toBeTruthy();
expect(container.textContent).toMatch(/Today/);
});
it("shows today's booking and hides the one from last week", async () => {
renderPage();
expect(await screen.findByText('DM-TODAY')).toBeInTheDocument();
expect(screen.queryByText('DM-OLD')).not.toBeInTheDocument();
});
it('the KPI counts the same day the table shows', async () => {
// Was the whole account before the filter existed. A "Customer bookings 2"
// over a one-row table is the failure this pins.
renderPage();
await screen.findByText('DM-TODAY');
expect(screen.getByText(/Customer bookings · today/i)).toBeInTheDocument();
});
it('says a date is filtering when the day is empty, not just "no bookings"', async () => {
drainBookings.mockResolvedValue({
rows: [booking('DM-OLD', `${lastWeek} 09:00:00`)],
truncated: false, scanned: 1, pagesFetched: 1, total: 1,
});
renderPage();
// "Nothing was booked" and "a filter is hiding it" are different facts and
// an operator has to be able to tell which one they are looking at.
expect(await screen.findByText(/date picker to widen the range/i)).toBeInTheDocument();
});
});

View File

@@ -224,7 +224,7 @@ describe('ZoneContext', () => {
return setup();
};
it.each(['hubid', 'sourcehubid', 'applocationid', 'tenantlocationid', 'locationid'])(
it.each(['hubid', 'sourcehubid', 'servicinghubid', 'pickuphubid', 'nearesthubid'])(
'should match on a direct %s',
(field) => {
const { result } = selectKoramangala();
@@ -232,6 +232,30 @@ describe('ZoneContext', () => {
}
);
// CHANGED. These three used to be asserted as matches, and that was the
// bug rather than the contract.
//
// A staff zone is a HUB. `applocationid` numbers cities, `tenantlocationid`
// and `locationid` number a client's own sites — so matching them against a
// hub id is a category error that lands orders in cities they never touched.
// A delivery row carries several at once (queries.js sets servicinghubid,
// applocationid and tenantlocationid together): DM-626241 is servicinghubid
// 5, Bangalore Earth Hub, alongside tenantlocationid 3, which read as a hub
// id is Hyderabad Mars Hub.
//
// The old `??` chain hid it by stopping at the first non-null field, so the
// collision only fired when the earlier fields happened to be missing.
//
// A tenant session is the mirror image and keeps its own assertions below:
// there the zone IS a tenantlocationid, and hub ids are the wrong space.
it.each(['applocationid', 'tenantlocationid', 'locationid'])(
'should NOT match a staff hub zone on a %s — different id space',
(field) => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ bookingid: 1, [field]: '2' })).toBe(false);
}
);
it('should match a numeric hub id against the string zone id', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ hubid: 2 })).toBe(true);

View File

@@ -0,0 +1,64 @@
import dayjs from 'dayjs';
import { filterByCreatedAt } from '@/lib/dateRange';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* The date filter the Orders and Bookings lists share.
*
* Both read the same rows from the same endpoint through the same picker, so
* the comparison has to be one piece of code. The shapes below are the three
* the picker actually emits — a plain day, a `{from, to}` preset, and the
* string 'all' — and getting any of them wrong empties the table while looking
* like a legitimate "no bookings today".
*/
const row = (createdat, bookingno) => ({ bookingno, createdat });
const ROWS = [
row('2026-09-22 15:48:00', 'DM-A'),
row('2026-09-23 10:10:00', 'DM-B'),
row('2026-09-24 10:58:00', 'DM-C'),
];
const nos = (rows) => rows.map((r) => r.bookingno);
describe('filterByCreatedAt', () => {
it('a single day keeps only that day', () => {
expect(nos(filterByCreatedAt(ROWS, '2026-09-23', parseDoormileTimestamp))).toEqual(['DM-B']);
});
it('a {from,to} preset keeps the inclusive span', () => {
const got = filterByCreatedAt(ROWS, { from: '2026-09-22', to: '2026-09-23' }, parseDoormileTimestamp);
expect(nos(got)).toEqual(['DM-A', 'DM-B']);
});
it("'all' keeps everything", () => {
expect(nos(filterByCreatedAt(ROWS, 'all', parseDoormileTimestamp))).toEqual(['DM-A', 'DM-B', 'DM-C']);
});
it('an open-ended from keeps that day onwards', () => {
const got = filterByCreatedAt(ROWS, { from: '2026-09-23', to: '' }, parseDoormileTimestamp);
expect(nos(got)).toEqual(['DM-B', 'DM-C']);
});
it('a row whose timestamp will not parse is dropped from a dated view', () => {
// and NOT from an undated one — the row exists, and "every booking" has to
// include it even when its timestamp is unusable.
const withJunk = [...ROWS, row('not a date', 'DM-JUNK')];
expect(nos(filterByCreatedAt(withJunk, '2026-09-24', parseDoormileTimestamp))).toEqual(['DM-C']);
expect(nos(filterByCreatedAt(withJunk, 'all', parseDoormileTimestamp))).toContain('DM-JUNK');
});
it('an empty or missing value is not a filter', () => {
expect(filterByCreatedAt(ROWS, '', parseDoormileTimestamp)).toHaveLength(3);
expect(filterByCreatedAt(ROWS, undefined, parseDoormileTimestamp)).toHaveLength(3);
});
it('survives a null row list', () => {
expect(filterByCreatedAt(null, '2026-09-24', parseDoormileTimestamp)).toEqual([]);
});
it("today's default shows today's bookings and nothing else", () => {
const today = dayjs().format('YYYY-MM-DD');
const rows = [row(`${today} 09:00:00`, 'TODAY'), row('2020-01-01 09:00:00', 'OLD')];
expect(nos(filterByCreatedAt(rows, today, parseDoormileTimestamp))).toEqual(['TODAY']);
});
});

View File

@@ -0,0 +1,134 @@
import { makeLocationFilter, ZONE_RADIUS_KM } from '@/lib/locationScope';
/**
* Control X's hub picker.
*
* Its `applocationid` was destructured out of the fetchDeliveries query key and
* then ignored — the same silent no-op the folder's CLAUDE.md records for
* startdate/enddate, and it survived the fix. Picking a hub rebuilt the key,
* refetched, and returned the same nationwide rows, so the header read
* "Coimbatore Neptune Hub" over a map drawing a Bangalore rider on Mysore Road.
*
* The hubs below are the real rows, including `Coimbatore Test Hub` at 0,0 —
* the Gulf of Guinea — which must never be used as a centre.
*/
const HUBS = [
{ hubid: 1, hubname: 'Coimbatore Jupiter Hub', applocationid: 1, latitude: 11.0168, longitude: 76.9558, pincode: '641012' },
{ hubid: 2, hubname: 'Coimbatore Neptune Hub', applocationid: 1, latitude: 11.0301, longitude: 77.0034, pincode: '641004' },
{ hubid: 17, hubname: 'Coimbatore Test Hub', applocationid: 1, latitude: 0, longitude: 0, pincode: '641099' },
{ hubid: 5, hubname: 'Bangalore Earth Hub', applocationid: 3, latitude: 12.9352, longitude: 77.6244, pincode: '560034' },
{ hubid: 11, hubname: 'Bangalore Whitefield Hub', applocationid: 3, latitude: 12.9698, longitude: 77.7499, pincode: '560066' },
];
const COIMBATORE = 1;
const BANGALORE = 3;
// DM-626241 as fetchDeliveries normalises it: Koramangala → Bangalore University.
const bangaloreRun = (over = {}) => ({
bookingid: 11, orderid: 'DM-626241', servicinghubid: 5,
pickuplatitude: 12.9352, pickuplongitude: 77.6244,
deliverylatitude: 12.94393, deliverylongitude: 77.50918,
...over,
});
const coimbatoreRun = (over = {}) => ({
bookingid: 9, orderid: 'DM-956478', servicinghubid: 1,
pickuplatitude: 11.0168, pickuplongitude: 76.9558,
deliverylatitude: 11.0183, deliverylongitude: 76.9680,
...over,
});
describe('the bug this closes', () => {
it('a Bangalore run does not appear under Coimbatore', () => {
const inCoimbatore = makeLocationFilter(HUBS, COIMBATORE);
expect(inCoimbatore(bangaloreRun())).toBe(false);
});
it('and still appears under Bangalore', () => {
const inBangalore = makeLocationFilter(HUBS, BANGALORE);
expect(inBangalore(bangaloreRun())).toBe(true);
});
it('a Coimbatore run appears under Coimbatore and not Bangalore', () => {
expect(makeLocationFilter(HUBS, COIMBATORE)(coimbatoreRun())).toBe(true);
expect(makeLocationFilter(HUBS, BANGALORE)(coimbatoreRun())).toBe(false);
});
});
describe('"All locations" is not a filter', () => {
it.each([0, '0', '', null, undefined, 'nonsense'])('%p returns null', (id) => {
expect(makeLocationFilter(HUBS, id)).toBeNull();
});
it('a city with no hubs fails open rather than emptying the board', () => {
// A blank board reads as "no work today". That is the more expensive
// mistake than showing too much, so an unresolvable picker shows all.
expect(makeLocationFilter(HUBS, 99)).toBeNull();
expect(makeLocationFilter([], COIMBATORE)).toBeNull();
expect(makeLocationFilter(null, COIMBATORE)).toBeNull();
});
});
describe('how a row is placed', () => {
it('by the hub it names, with no coordinates at all', () => {
const inBangalore = makeLocationFilter(HUBS, BANGALORE);
expect(inBangalore({ orderid: 'X', pickuphubid: 11 })).toBe(true);
expect(makeLocationFilter(HUBS, COIMBATORE)({ orderid: 'X', pickuphubid: 11 })).toBe(false);
});
it('by either end of the journey, not just the pickup', () => {
// A run that collects in Coimbatore and delivers there is Coimbatore's
// work whichever end you look at; one that only arrives is too.
const inCoimbatore = makeLocationFilter(HUBS, COIMBATORE);
const inbound = { orderid: 'IN', pickuplatitude: 12.9352, pickuplongitude: 77.6244,
deliverylatitude: 11.0168, deliverylongitude: 76.9558 };
expect(inCoimbatore(inbound)).toBe(true);
});
it('a stop just outside the radius does not belong', () => {
const inBangalore = makeLocationFilter(HUBS, BANGALORE);
// ~0.01 degree of latitude is ~1.1km, so 0.5 degrees is ~55km north.
const farAway = { orderid: 'FAR', pickuplatitude: 13.45, pickuplongitude: 77.6244,
deliverylatitude: 13.46, deliverylongitude: 77.6244 };
expect(inBangalore(farAway)).toBe(false);
});
it('the radius is the one ZoneContext uses', () => {
expect(ZONE_RADIUS_KM).toBe(35);
});
});
describe('a hub at 0,0 is not a centre', () => {
it('Coimbatore Test Hub does not pull the Gulf of Guinea into Coimbatore', () => {
// hub 17 is latitude 0, longitude 0 in the live data. Treated as a real
// point it would claim everything within 35km of the equator's origin.
const inCoimbatore = makeLocationFilter(HUBS, COIMBATORE);
expect(inCoimbatore({ orderid: 'NULL_ISLAND', pickuplatitude: 0.1, pickuplongitude: 0.1,
deliverylatitude: 0.1, deliverylongitude: 0.1 })).toBe(false);
});
it('but its hub id still places a row, since the id is not a guess', () => {
expect(makeLocationFilter(HUBS, COIMBATORE)({ orderid: 'T', servicinghubid: 17 })).toBe(true);
});
});
describe('a row nobody can place stays visible', () => {
it('no hub id and no usable coordinates — kept under every city', () => {
// Dropping it would remove it from ALL cities at once, with nothing on
// screen to say it had been hidden. That is the failure mode this whole
// area keeps producing, and it is worse than one stray row.
const orphan = { orderid: 'ORPHAN' };
expect(makeLocationFilter(HUBS, COIMBATORE)(orphan)).toBe(true);
expect(makeLocationFilter(HUBS, BANGALORE)(orphan)).toBe(true);
});
it('a row with 0,0 coordinates counts as unplaceable, not as an address', () => {
const unpinned = { orderid: 'ZERO', pickuplatitude: 0, pickuplongitude: 0,
deliverylatitude: 0, deliverylongitude: 0 };
expect(makeLocationFilter(HUBS, COIMBATORE)(unpinned)).toBe(true);
});
it('a null row is not a row', () => {
expect(makeLocationFilter(HUBS, COIMBATORE)(null)).toBe(false);
});
});

View File

@@ -0,0 +1,180 @@
import { renderHook, act } from '@testing-library/react';
jest.mock('@/lib/doormileHooks', () => ({
__esModule: true,
useHubs: jest.fn(),
useTenantLocations: jest.fn()
}));
jest.mock('@/lib/AuthContext', () => ({ __esModule: true, useAuth: jest.fn() }));
import { ZoneProvider, useZone } from '@/lib/ZoneContext';
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
import { useAuth } from '@/lib/AuthContext';
/**
* Which orders belong to the selected zone.
*
* These fixtures are the shapes the API really returns, not convenient ones.
* `GET /admin/hubs` serves models.Hub, and `GET /admin/bookings` serves
* models.PickupBooking — so a hub here carries `applocationid` and `city`, and
* a booking carries `pickuphubid` and `nearesthubid` and NOT `hubid`,
* `sourcehubid` or `servicinghubid`. The older suite in ZoneContext.test.jsx
* uses a hub fixture with a `city` the endpoint did not send, which is how a
* green suite sat on top of a city comparison that could never run.
*/
// Hub 5, Bengaluru. `city` is populated by attachHubCities in adminController.
const BLR_EARTH = {
hubid: 5, hubname: 'Bangalore Earth Hub', hubtype: 'sorting_center',
applocationid: 3, city: 'Bangalore', address: '', latitude: 12.9352,
longitude: 77.6244, pincode: '560034', status: 'Active'
};
const CBE_JUPITER = {
hubid: 1, hubname: 'Coimbatore Jupiter Hub', hubtype: 'sorting_center',
applocationid: 1, city: 'Coimbatore', address: '', latitude: 11.0168,
longitude: 76.9558, pincode: '641012', status: 'Active'
};
const wrapper = ({ children }) => <ZoneProvider>{children}</ZoneProvider>;
const setup = ({ user = { tenantid: null }, hubs = [CBE_JUPITER, BLR_EARTH], locations = [] } = {}) => {
useAuth.mockReturnValue({ user });
useHubs.mockReturnValue({ data: hubs, isLoading: false, refetch: jest.fn() });
useTenantLocations.mockReturnValue({ data: locations, isLoading: false, refetch: jest.fn() });
return renderHook(() => useZone(), { wrapper });
};
const inZone = (result, zoneId, item) => {
act(() => result.current.setSelectedZoneId(zoneId));
return result.current.matchesZone(item);
};
beforeEach(() => localStorage.clear());
describe('a booking is matched by the hub it actually records', () => {
it('pickuphubid alone puts the order in that zone', () => {
// The column exists, is populated by createExpressBooking, and was being
// ignored. Nothing else on this row can place it: no coordinates, and an
// address that names neither the hub nor its city.
const { result } = setup();
expect(inZone(result, '5', {
bookingid: 1, bookingno: 'DM-BLR001', pickuphubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(true);
});
it('nearesthubid alone puts the order in that zone', () => {
const { result } = setup();
expect(inZone(result, '5', {
bookingid: 2, bookingno: 'DM-BLR002', nearesthubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(true);
});
it('and does NOT put it in a different hub\'s zone', () => {
const { result } = setup();
expect(inZone(result, '1', {
bookingid: 1, bookingno: 'DM-BLR001', pickuphubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(false);
});
});
describe('a tenant session never matches on a hub id', () => {
const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen', city: 'Bengaluru',
latitude: 12.9784, longitude: 77.6408, pincode: '560038' };
it('a booking whose pickuphubid is 5 does not land in tenant location 5', () => {
// Two different id spaces. Matching them would file an order under a
// kitchen it has nothing to do with, on a bare numeric collision.
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', {
bookingid: 3, bookingno: 'DM-X', tenantid: '13', pickuphubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(false);
});
it('but its own tenantlocationid still matches', () => {
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', {
bookingid: 4, bookingno: 'DM-Y', tenantid: '13', tenantlocationid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(true);
});
});
describe('the city comparison, which needs a city on the hub', () => {
it('matches a Bangalore address against the Bangalore hub', () => {
// `hub.city` comes from the applocations join. Before that field existed
// this branch read '' and was skipped on every staff session, leaving a
// 35km radius as the only thing still matching.
const { result } = setup();
expect(inZone(result, '5', {
bookingid: 5, bookingno: 'DM-BLR003',
pickupaddress: '80 Feet Road, Koramangala, Bangalore',
deliveryaddress: '100 Feet Road, Indiranagar, Bangalore'
})).toBe(true);
});
it('a hub with no city does not match everything', () => {
// Failing open here would put every order in every zone.
const { result } = setup({ hubs: [{ ...BLR_EARTH, city: undefined, latitude: undefined, longitude: undefined }] });
expect(inZone(result, '5', {
bookingid: 6, bookingno: 'DM-Z',
pickupaddress: 'Somewhere else entirely', deliveryaddress: 'Also elsewhere'
})).toBe(false);
});
});
describe('an order with no coordinates is not silently lost', () => {
it('still reaches its zone through pickuphubid', () => {
// The case that started this: coordinates were the only working matcher,
// so an order saved without them was invisible in every zone at once.
const { result } = setup();
const noCoords = {
bookingid: 7, bookingno: 'DM-NOCOORD', pickuphubid: 5,
pickuplatitude: 0, pickuplongitude: 0,
deliverylatitude: 0, deliverylongitude: 0,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
};
expect(inZone(result, '5', noCoords)).toBe(true);
expect(inZone(result, '1', noCoords)).toBe(false);
});
});
describe('one id space at a time', () => {
// queries.js puts servicinghubid, applocationid and tenantlocationid on the
// SAME delivery row. They are three different numbering schemes, and testing
// them all against a hub id files orders under cities they never touched.
const DM626241 = {
bookingid: 11, bookingno: 'DM-626241',
servicinghubid: 5, // Bangalore Earth Hub — the real owner
tenantlocationid: 3, // a client site; as a HUB id this is Hyderabad
applocationid: 3, // a CITY id; as a hub id this is Hyderabad too
pickupaddress: 'Koramangala', deliveryaddress: 'Mysore Road',
};
const HYDERABAD_MARS = {
hubid: 3, hubname: 'Hyderabad Mars Hub', applocationid: 2, city: 'Hyderabad',
latitude: 17.4483, longitude: 78.3741, pincode: '500032', status: 'Active',
};
it('a Bangalore run does not land in Hyderabad on a numeric collision', () => {
const { result } = setup({ hubs: [CBE_JUPITER, BLR_EARTH, HYDERABAD_MARS] });
expect(inZone(result, '3', DM626241)).toBe(false);
});
it('and still lands in Bangalore, by the hub it actually names', () => {
const { result } = setup({ hubs: [CBE_JUPITER, BLR_EARTH, HYDERABAD_MARS] });
expect(inZone(result, '5', DM626241)).toBe(true);
});
it('a tenant session matches its own locations and no hub id', () => {
const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen',
city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408, pincode: '560038' };
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', { bookingid: 1, tenantid: '13', tenantlocationid: 5,
pickupaddress: 'a', deliveryaddress: 'b' })).toBe(true);
expect(inZone(result, '5', { bookingid: 2, tenantid: '13', servicinghubid: 5,
pickupaddress: 'a', deliveryaddress: 'b' })).toBe(false);
});
});