updates on bugs on the dispatch and ai button integrations

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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