updates on bugs on the dispatch and ai button integrations
This commit is contained in:
@@ -3,6 +3,7 @@ import { OpenToast } from './notify';
|
|||||||
import logger from '@/lib/logger';
|
import logger from '@/lib/logger';
|
||||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||||
import { buildAnchors, classifyBooking } from '@/lib/orderFlow';
|
import { buildAnchors, classifyBooking } from '@/lib/orderFlow';
|
||||||
|
import { makeLocationFilter } from '@/lib/locationScope';
|
||||||
import {
|
import {
|
||||||
getMilers,
|
getMilers,
|
||||||
getMiler,
|
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
|
// 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
|
// silent 400 or, worse, a silently-ignored filter), so the range is applied
|
||||||
// client-side below, after the rows are normalised.
|
// 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
|
// Unlike the 3 joins below (customers/milers/tenants — each individually
|
||||||
// guarded so a failed join just degrades a display field, not the whole
|
// 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
|
// 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
|
// 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
|
// carries no recognisable status, the row falls back to the booking's status
|
||||||
// and behaviour is exactly what it was before this join existed.
|
// 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(() => []),
|
getAdminCustomers().catch(() => []),
|
||||||
getMilers().catch(() => []),
|
getMilers().catch(() => []),
|
||||||
getAdminTenants().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
|
// The id field on a consignment record has never been captured, so both
|
||||||
// plausible names are indexed rather than guessing one.
|
// plausible names are indexed rather than guessing one.
|
||||||
@@ -1060,8 +1086,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
|||||||
return true;
|
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 {
|
return {
|
||||||
rows: rows.filter(inRange),
|
rows: inLocation ? rows.filter(inRange).filter(inLocation) : rows.filter(inRange),
|
||||||
nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
78
src/components/assistant/DoormileAI/AILauncher.jsx
Normal file
78
src/components/assistant/DoormileAI/AILauncher.jsx
Normal 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;
|
||||||
@@ -18,8 +18,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
|
|||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { useExceptions } from '@/lib/doormileHooks';
|
import { useExceptions } from '@/lib/doormileHooks';
|
||||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||||
import doormileMark from 'assets/images/doormile-mark.png';
|
|
||||||
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
|
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
|
||||||
|
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console shell.
|
* The console shell.
|
||||||
@@ -387,58 +387,10 @@ export default function AdminLayout() {
|
|||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
|
|
||||||
{/* MileTruth. A header control rather than a route: shown on all pages except the home page */}
|
{/* MileTruth's control is no longer here. It is the floating
|
||||||
{!isHome && (
|
launcher at the bottom of the page (AILauncher, mounted beside
|
||||||
<button
|
the panel below), so this row holds only the utilities that
|
||||||
type="button"
|
belong in a header: search, alerts, the account menu. */}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isClient && <NotificationsMenu />}
|
{!isClient && <NotificationsMenu />}
|
||||||
|
|
||||||
@@ -562,7 +514,16 @@ export default function AdminLayout() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
|
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
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';
|
const ZONE_STORAGE_KEY = 'doormile_active_zone_id';
|
||||||
|
|
||||||
@@ -13,17 +16,6 @@ const ALL_ZONE = Object.freeze({
|
|||||||
status: 'Active',
|
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);
|
const ZoneContext = createContext(null);
|
||||||
|
|
||||||
export function ZoneProvider({ children }) {
|
export function ZoneProvider({ children }) {
|
||||||
@@ -147,21 +139,40 @@ export function ZoneProvider({ children }) {
|
|||||||
|
|
||||||
// 1. Direct hub/location id match
|
// 1. Direct hub/location id match
|
||||||
//
|
//
|
||||||
// `servicinghubid` is queries.js's answer to "which hub OWNS this order",
|
// The hub ids a booking can actually carry. `pickuphubid` and
|
||||||
// which is a different question from "where is it collected from" and the
|
// `nearesthubid` are the only hub foreign keys that exist on the
|
||||||
// only one that stays answerable once the pickup is a customer's doorstep.
|
// pickupbookings row; `servicinghubid` is synthesised by queries.js and so
|
||||||
// It is consulted first, and only for staff sessions: a tenant's zones are
|
// reaches this function on Deliveries but never on Orders; `hubid`,
|
||||||
// its own locations, so a hub id compared against a tenantlocationid would
|
// `sourcehubid`, `applocationid` and `locationid` are not columns on a
|
||||||
// match on a bare numeric collision and file the order under a zone that
|
// booking at all and are kept only for the other row shapes that pass
|
||||||
// has nothing to do with it.
|
// through here.
|
||||||
const itemHubId =
|
//
|
||||||
(!isTenantUser ? item.servicinghubid : null) ??
|
// Until `pickuphubid` was added, every one of those was undefined on the
|
||||||
item.hubid ??
|
// Orders page, so this whole branch fell through and the 35km radius below
|
||||||
item.sourcehubid ??
|
// was the only matcher left running — which means an order that reached the
|
||||||
item.applocationid ??
|
// database without coordinates disappeared from every zone at once, with
|
||||||
item.tenantlocationid ??
|
// nowhere to see it and nothing to say it had been hidden.
|
||||||
item.locationid;
|
//
|
||||||
if (itemHubId != null && String(itemHubId) === targetHubId) {
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +195,7 @@ export function ZoneProvider({ children }) {
|
|||||||
return true;
|
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 hubLat = Number(currentHub.latitude);
|
||||||
const hubLon = Number(currentHub.longitude);
|
const hubLon = Number(currentHub.longitude);
|
||||||
if (Number.isFinite(hubLat) && Number.isFinite(hubLon)) {
|
if (Number.isFinite(hubLat) && Number.isFinite(hubLon)) {
|
||||||
@@ -196,8 +207,8 @@ export function ZoneProvider({ children }) {
|
|||||||
const pickDist = haversineKm(hubLat, hubLon, pickLat, pickLon);
|
const pickDist = haversineKm(hubLat, hubLon, pickLat, pickLon);
|
||||||
const dropDist = haversineKm(hubLat, hubLon, dropLat, dropLon);
|
const dropDist = haversineKm(hubLat, hubLon, dropLat, dropLon);
|
||||||
|
|
||||||
if (pickDist !== undefined && pickDist <= 35) return true;
|
if (pickDist !== undefined && pickDist <= ZONE_RADIUS_KM) return true;
|
||||||
if (dropDist !== undefined && dropDist <= 35) return true;
|
if (dropDist !== undefined && dropDist <= ZONE_RADIUS_KM) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -20,3 +20,32 @@ export const toDateRange = (value) => {
|
|||||||
if (typeof value !== 'object') return { from: '', to: '' };
|
if (typeof value !== 'object') return { from: '', to: '' };
|
||||||
return { from: value.from || '', to: value.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
106
src/lib/locationScope.js
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -435,6 +435,12 @@ export const classifyBooking = (booking, anchors = []) => {
|
|||||||
const servicingHubId =
|
const servicingHubId =
|
||||||
b.sourcehubid ??
|
b.sourcehubid ??
|
||||||
b.hubid ??
|
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?.kind === 'hub' ? originAnchor.hubid : null) ??
|
||||||
originAnchor?.hubid ??
|
originAnchor?.hubid ??
|
||||||
(destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ??
|
(destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ??
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
Ban, Bike,
|
Ban, Bike, FileSpreadsheet,
|
||||||
Package, PackageSearch, RefreshCw,
|
Package, PackageSearch, RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert, Button, DataTable, Drawer, EmptyState, Grid, KpiCard,
|
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Grid, KpiCard,
|
||||||
Modal, Stack, StatusBadge, Surface, Tabs,
|
Modal, Stack, StatusBadge, Surface, Tabs,
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
@@ -23,7 +24,8 @@ import {
|
|||||||
quotedPrice, serviceOptionOf, serviceType,
|
quotedPrice, serviceOptionOf, serviceType,
|
||||||
} from '@/lib/customerAppBookings';
|
} from '@/lib/customerAppBookings';
|
||||||
import { currency, exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
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';
|
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -223,7 +225,14 @@ function BookingDrawer({ bookingId, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Bookings() {
|
export default function Bookings() {
|
||||||
|
const todayStr = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
const [tab, setTab] = useState('all');
|
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 [search, setSearch] = useState('');
|
||||||
const [openId, setOpenId] = useState(null);
|
const [openId, setOpenId] = useState(null);
|
||||||
const [selectedIds, setSelectedIds] = useState([]);
|
const [selectedIds, setSelectedIds] = useState([]);
|
||||||
@@ -244,7 +253,32 @@ export default function Bookings() {
|
|||||||
refetchOnMount: 'always',
|
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
|
// Tab counts
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
@@ -270,10 +304,11 @@ export default function Bookings() {
|
|||||||
[rows, selectedIds]
|
[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(() => {
|
useEffect(() => {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
}, [tab]);
|
}, [tab, selectedDate]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const created = counts.created;
|
const created = counts.created;
|
||||||
@@ -432,9 +467,9 @@ export default function Bookings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Grid cols={3} gap="default">
|
<Grid cols={3} gap="default">
|
||||||
<KpiCard label="Customer bookings" value={stats.total} />
|
<KpiCard label={`Customer bookings · ${dateLabel}`} value={stats.total} />
|
||||||
<KpiCard label="Created (Awaiting Rider)" value={stats.created} />
|
<KpiCard label={`Created (Awaiting Rider) · ${dateLabel}`} value={stats.created} />
|
||||||
<KpiCard label="Total quoted" value={currency(stats.value)} />
|
<KpiCard label={`Total quoted · ${dateLabel}`} value={currency(stats.value)} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
|
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
|
||||||
@@ -456,14 +491,35 @@ export default function Bookings() {
|
|||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
placeholder="Search booking no, address or status"
|
placeholder="Search booking no, address or status"
|
||||||
onExport={() => exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}`)}
|
|
||||||
exportDisabled={rows.length === 0}
|
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" onClick={() => refetch()} loading={isFetching}>
|
<>
|
||||||
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
|
{/* Export lives in `actions` because that is the only slot
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -483,8 +539,17 @@ export default function Bookings() {
|
|||||||
debouncedSearch ? undefined : (
|
debouncedSearch ? undefined : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Package}
|
icon={Package}
|
||||||
title={`No ${BOOKING_TABS.find((entry) => entry.value === tab)?.label.toLowerCase()} bookings`}
|
title={`No ${BOOKING_TABS.find((entry) => entry.value === tab)?.label.toLowerCase()} bookings ${
|
||||||
description="Customer app bookings will appear here as they are placed."
|
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.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,25 @@
|
|||||||
to go below its content width, which is what forced the overflow onto
|
to go below its content width, which is what forced the overflow onto
|
||||||
the items to its right. */
|
the items to its right. */
|
||||||
min-width: 0;
|
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.
|
/* The badge is sized to the D ITSELF, not to the image file.
|
||||||
@@ -120,6 +138,20 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: -0.02em;
|
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 {
|
.dispatch-container .logo-name em {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { OpenToast, messageOf } from '@/api/doormile/notify';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
|
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
|
||||||
import { FLOW, classifyBooking } from '@/lib/orderFlow';
|
import { FLOW, classifyBooking } from '@/lib/orderFlow';
|
||||||
|
import { filterByCreatedAt } from '@/lib/dateRange';
|
||||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||||
import { dropSummary, isMultiDrop } from '@/lib/bookingDrops';
|
import { dropSummary, isMultiDrop } from '@/lib/bookingDrops';
|
||||||
@@ -122,24 +123,14 @@ export default function Orders() {
|
|||||||
});
|
});
|
||||||
}, [bookings]);
|
}, [bookings]);
|
||||||
|
|
||||||
const dateFilteredBookings = useMemo(() => {
|
/* Shared with the Bookings list, which reads the same rows through the same
|
||||||
if (!selectedDate || selectedDate === 'all') return sortedBookings;
|
picker. Two copies of this comparison would eventually disagree about what
|
||||||
return sortedBookings.filter((booking) => {
|
a given day contains, and the two screens would quietly show different
|
||||||
const at = parseDoormileTimestamp(booking.createdat);
|
counts for it. */
|
||||||
if (!at.isValid()) return false;
|
const dateFilteredBookings = useMemo(
|
||||||
if (typeof selectedDate === 'string') {
|
() => filterByCreatedAt(sortedBookings, selectedDate, parseDoormileTimestamp),
|
||||||
return at.format('YYYY-MM-DD') === selectedDate;
|
[sortedBookings, 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]);
|
|
||||||
|
|
||||||
const zoneBookings = useMemo(() => {
|
const zoneBookings = useMemo(() => {
|
||||||
return dateFilteredBookings.filter(matchesZone);
|
return dateFilteredBookings.filter(matchesZone);
|
||||||
|
|||||||
@@ -71,11 +71,77 @@ const withNoJoins = () => {
|
|||||||
endpoints.getMilers.mockResolvedValue([]);
|
endpoints.getMilers.mockResolvedValue([]);
|
||||||
endpoints.getAdminTenants.mockResolvedValue([]);
|
endpoints.getAdminTenants.mockResolvedValue([]);
|
||||||
endpoints.getConsignments.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', () => {
|
describe('deliveries data layer', () => {
|
||||||
beforeEach(withNoJoins);
|
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', () => {
|
describe('fetchDeliveries — dispatched filtering', () => {
|
||||||
it('should surface a booking that already has a rider assigned', async () => {
|
it('should surface a booking that already has a rider assigned', async () => {
|
||||||
endpoints.getBookings.mockResolvedValue([booking()]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
87
tests/ds/aiLauncher.test.jsx
Normal file
87
tests/ds/aiLauncher.test.jsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
tests/ds/dispatchHeaderClipping.test.js
Normal file
70
tests/ds/dispatchHeaderClipping.test.js
Normal 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\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
tests/integration/bookingsDatePicker.test.jsx
Normal file
106
tests/integration/bookingsDatePicker.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -224,7 +224,7 @@ describe('ZoneContext', () => {
|
|||||||
return setup();
|
return setup();
|
||||||
};
|
};
|
||||||
|
|
||||||
it.each(['hubid', 'sourcehubid', 'applocationid', 'tenantlocationid', 'locationid'])(
|
it.each(['hubid', 'sourcehubid', 'servicinghubid', 'pickuphubid', 'nearesthubid'])(
|
||||||
'should match on a direct %s',
|
'should match on a direct %s',
|
||||||
(field) => {
|
(field) => {
|
||||||
const { result } = selectKoramangala();
|
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', () => {
|
it('should match a numeric hub id against the string zone id', () => {
|
||||||
const { result } = selectKoramangala();
|
const { result } = selectKoramangala();
|
||||||
expect(result.current.matchesZone({ hubid: 2 })).toBe(true);
|
expect(result.current.matchesZone({ hubid: 2 })).toBe(true);
|
||||||
|
|||||||
64
tests/lib/filterByCreatedAt.test.js
Normal file
64
tests/lib/filterByCreatedAt.test.js
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
tests/lib/locationScope.test.js
Normal file
134
tests/lib/locationScope.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
tests/lib/zoneHubIds.test.jsx
Normal file
180
tests/lib/zoneHubIds.test.jsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user