From cbce47f949d9f439ed31c6d8a2e812f4c446faa7 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 24 Sep 2026 11:50:04 +0530 Subject: [PATCH] updates on bugs on the dispatch and ai button integrations --- src/api/doormile/queries.js | 38 +++- .../assistant/DoormileAI/AILauncher.jsx | 78 ++++++++ src/layouts/AdminLayout.jsx | 69 ++----- src/lib/ZoneContext.jsx | 69 ++++--- src/lib/dateRange.js | 29 +++ src/lib/locationScope.js | 106 +++++++++++ src/lib/orderFlow.js | 6 + src/pages/doormile/bookings/Bookings.jsx | 99 ++++++++-- src/pages/doormile/dispatch/Dispatch.css | 34 +++- src/pages/doormile/orders/Orders.jsx | 27 +-- tests/api/deliveries.test.js | 92 +++++++++ tests/ds/aiLauncher.test.jsx | 87 +++++++++ tests/ds/dispatchHeaderClipping.test.js | 70 +++++++ tests/integration/bookingsDatePicker.test.jsx | 106 +++++++++++ tests/lib/ZoneContext.test.jsx | 26 ++- tests/lib/filterByCreatedAt.test.js | 64 +++++++ tests/lib/locationScope.test.js | 134 +++++++++++++ tests/lib/zoneHubIds.test.jsx | 180 ++++++++++++++++++ 18 files changed, 1190 insertions(+), 124 deletions(-) create mode 100644 src/components/assistant/DoormileAI/AILauncher.jsx create mode 100644 src/lib/locationScope.js create mode 100644 tests/ds/aiLauncher.test.jsx create mode 100644 tests/ds/dispatchHeaderClipping.test.js create mode 100644 tests/integration/bookingsDatePicker.test.jsx create mode 100644 tests/lib/filterByCreatedAt.test.js create mode 100644 tests/lib/locationScope.test.js create mode 100644 tests/lib/zoneHubIds.test.jsx diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index 0f600cc..340f070 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -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 }; }; diff --git a/src/components/assistant/DoormileAI/AILauncher.jsx b/src/components/assistant/DoormileAI/AILauncher.jsx new file mode 100644 index 0000000..2149f61 --- /dev/null +++ b/src/components/assistant/DoormileAI/AILauncher.jsx @@ -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 ( + + ); +}; + +AILauncher.propTypes = { + isOpen: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + className: PropTypes.string, +}; + +export default AILauncher; diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx index 1332864..366ecdc 100644 --- a/src/layouts/AdminLayout.jsx +++ b/src/layouts/AdminLayout.jsx @@ -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() {
- {/* MileTruth. A header control rather than a route: shown on all pages except the home page */} - {!isHome && ( - - )} + {/* 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 && } @@ -562,7 +514,16 @@ export default function AdminLayout() {
- {!isHome && 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 && ( + <> + toggleAssistant((open) => !open)} /> + toggleAssistant(false)} /> + + )} ); } diff --git a/src/lib/ZoneContext.jsx b/src/lib/ZoneContext.jsx index 690d237..493fcbb 100644 --- a/src/lib/ZoneContext.jsx +++ b/src/lib/ZoneContext.jsx @@ -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; diff --git a/src/lib/dateRange.js b/src/lib/dateRange.js index 38d78e5..fb59407 100644 --- a/src/lib/dateRange.js +++ b/src/lib/dateRange.js @@ -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; + }); +}; diff --git a/src/lib/locationScope.js b/src/lib/locationScope.js new file mode 100644 index 0000000..1c58d3f --- /dev/null +++ b/src/lib/locationScope.js @@ -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; + }; +}; diff --git a/src/lib/orderFlow.js b/src/lib/orderFlow.js index b9242ef..ca79590 100644 --- a/src/lib/orderFlow.js +++ b/src/lib/orderFlow.js @@ -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) ?? diff --git a/src/pages/doormile/bookings/Bookings.jsx b/src/pages/doormile/bookings/Bookings.jsx index dd1aeb1..21b9bed 100644 --- a/src/pages/doormile/bookings/Bookings.jsx +++ b/src/pages/doormile/bookings/Bookings.jsx @@ -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() { )} - - - + + +
@@ -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={ - + <> + {/* 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. */} + + + } - /> + > + +
@@ -483,8 +539,17 @@ export default function Bookings() { debouncedSearch ? undefined : ( 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.' + } /> ) } diff --git a/src/pages/doormile/dispatch/Dispatch.css b/src/pages/doormile/dispatch/Dispatch.css index dfff37c..fbb6767 100644 --- a/src/pages/doormile/dispatch/Dispatch.css +++ b/src/pages/doormile/dispatch/Dispatch.css @@ -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 { diff --git a/src/pages/doormile/orders/Orders.jsx b/src/pages/doormile/orders/Orders.jsx index cceb77e..26e23c1 100644 --- a/src/pages/doormile/orders/Orders.jsx +++ b/src/pages/doormile/orders/Orders.jsx @@ -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); diff --git a/tests/api/deliveries.test.js b/tests/api/deliveries.test.js index d2f3b0b..5aba2c1 100644 --- a/tests/api/deliveries.test.js +++ b/tests/api/deliveries.test.js @@ -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); + }); +}); diff --git a/tests/ds/aiLauncher.test.jsx b/tests/ds/aiLauncher.test.jsx new file mode 100644 index 0000000..119d2dd --- /dev/null +++ b/tests/ds/aiLauncher.test.jsx @@ -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) => ), + }) +); + +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(); + 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(); + 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'); + }); +}); diff --git a/tests/ds/dispatchHeaderClipping.test.js b/tests/ds/dispatchHeaderClipping.test.js new file mode 100644 index 0000000..0d84943 --- /dev/null +++ b/tests/ds/dispatchHeaderClipping.test.js @@ -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\)/); + }); +}); diff --git a/tests/integration/bookingsDatePicker.test.jsx b/tests/integration/bookingsDatePicker.test.jsx new file mode 100644 index 0000000..1aef54e --- /dev/null +++ b/tests/integration/bookingsDatePicker.test.jsx @@ -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 }) =>
{children}
, + DropdownMenuTrigger: ({ children }) =>
{children}
, + DropdownMenuContent: ({ children }) =>
{children}
, + DropdownMenuItem: ({ children, onClick }) => , + DropdownMenuLabel: ({ children }) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + +jest.mock('lucide-react', () => + new Proxy({}, { + get: (_t, prop) => (prop === '__esModule' ? true : (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( + + + + ); +}; + +/** + * 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(); + }); +}); diff --git a/tests/lib/ZoneContext.test.jsx b/tests/lib/ZoneContext.test.jsx index e2cd983..c2e2150 100644 --- a/tests/lib/ZoneContext.test.jsx +++ b/tests/lib/ZoneContext.test.jsx @@ -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); diff --git a/tests/lib/filterByCreatedAt.test.js b/tests/lib/filterByCreatedAt.test.js new file mode 100644 index 0000000..bf51d59 --- /dev/null +++ b/tests/lib/filterByCreatedAt.test.js @@ -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']); + }); +}); diff --git a/tests/lib/locationScope.test.js b/tests/lib/locationScope.test.js new file mode 100644 index 0000000..76d6943 --- /dev/null +++ b/tests/lib/locationScope.test.js @@ -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); + }); +}); diff --git a/tests/lib/zoneHubIds.test.jsx b/tests/lib/zoneHubIds.test.jsx new file mode 100644 index 0000000..15cb9f0 --- /dev/null +++ b/tests/lib/zoneHubIds.test.jsx @@ -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 }) => {children}; + +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); + }); +});