Two bodies of work that arrived in one working tree and are intertwined in
four files, so they commit together rather than pretending to a split the
diffs do not have.
TEAM WORK (pre-existing in the tree, uncommitted)
- "Riders" -> "Milers" across en.json and ~20 pages. Done carefully:
Deliveries.jsx checks BOTH prefixes ("Miler #" and "Rider #") so rows
written before the rename still render, and routes and query keys stay
rider*/riderssummary — renaming those would have broken bookmarks and
cache keys.
- MileTruth assistant rework: rename from "Doormile AI", HStack composer,
maximise/minimise/reset controls, open state persisted to localStorage.
- New /doormile/home landing page; / and /doormile now redirect there
instead of /doormile/dispatch. AdminLayout nav restructured with icons
and descriptions.
FIXES
Tab strip was unreachable (Deliveries: Delivered and Cancelled)
The pill variant is one non-wrapping inline-flex row. Given less width
than it needs, flex COMPRESSED it — 872px squeezed into 780px, truncating
labels inside the buttons — and with nothing scrollable no gesture could
recover the last two tabs. Four of six tab pages had each hand-rolled the
same wrapper; Deliveries and CompetitiveIntel had not. Fixed in the
component, so no page can forget it. Verified in a browser at the real
content width: 102px of scroll range, "Cancelled" reachable. 11 tests.
Home.jsx crash: Cannot read properties of null (reading 'flow')
Introduced by the change below, which made deriveVisualData return null.
All 12 .flow/.table dereferences are now behind visual?. gates.
Home.jsx presented fabricated figures as live operations
deriveVisualData keyword-matched the prompt and returned hardcoded values
for whole topics — revenue as a flat Rs 48,650, a workforce of 46 with 38
active, six named hubs, four booking numbers — ignoring the real result it
had been handed. Those branches sat ABOVE the one reading res.stats, so
the correct code was unreachable: asking about revenue could not return
the real number because a literal answered first.
Two were worse than wrong figures. The default branch returned a staffing
table for ANY unmatched question. And the no-answer path built a
sourceCalls entry claiming /admin/milers had been called, status
"complete", "46 milers scanned" — forging the provenance trail that exists
so an operator can check where a number came from. A fabricated figure is
a bug; a fabricated citation defeats the mechanism for catching one.
190 lines removed. Every branch now reads the result and returns null when
there is nothing to draw. No-answer says so; errors report as errors.
Found while verifying: 'Inactive'.includes('active') is true, so every hub
counted as active — carried from the original, whose own sample data
tripped it (six hubs, one Inactive, shown as 6/6). Now an explicit
vocabulary match, with unknown treated as down: a hub wrongly shown
offline gets investigated, one wrongly shown online hides an outage.
Home launcher tiles named pages that do not exist
Every tile now names its destination. Task Board -> Tripsheets,
Staff -> Milers, Availability -> Milers Summary (it pointed at the same
page as the tile beside it), Compliance -> Exceptions,
Invoices -> Bookings, Payroll -> Profitability,
AI Reports -> Orders Summary. Group headings: WORKFORCE SUITE -> FLEET,
FINANCE & CRM -> CLIENTS & REVENUE, AI TOOLS -> REPORTS & AI. "Invoices",
"Payroll" and "CRM" named systems Doormile does not run.
Four hardcoded badges removed (3, 38, 7, "New"). None was computed. A red
badge means "this many things need you", and one that never changes
teaches operators to ignore the real ones.
Navbar MileTruth control misaligned, hover dead
One cause: a 36px image (h-9 w-9 max-w-none) inside a 32px button. It
stood proud of the bell and avatar AND covered its own button, leaving
hover:bg-surface-sunken nowhere to render. Now 20px.
Assistant dock 30% -> 20%, and responsive
clamp(320px, 20vw, 460px) rather than max(): the cap is what makes a large
monitor work, since 20vw is 768px at 4K. Verified across seven screen
classes — 384px/20% at 1920, capped at 460px beyond 2300px, full-width
overlay at or below 900px. At 1024px the 320px floor wins (31%), because
20vw would be 205px and too narrow for the composer.
Suggestion chips took a third of the panel
.dai-suggestions was flex-direction:column, overriding the component's own
wrap="wrap", so four chips became four full rows. Row + wrap, trimmed
padding, ellipsis on the text span where text-overflow can act. Measured
at the 320px floor: 4 rows/177px -> 2 rows/84px, nothing clipped.
Pricing and Customers were unreachable on mobile
Moving them off the nav bar into the account dropdown removed them from
the phone entirely — the mobile sheet builds from NAV plus NAV_GROUPS and
they were in neither. Both stayed routed, so only a typed URL reached
them. Now one SETTINGS_NAV array that the dropdown and the sheet share.
calculateDrivingDistance was called and never imported
CreateOrder.jsx:371 — a ReferenceError on every customer-pickup order with
a pinned collection address, thrown synchronously inside a useEffect so
the .catch() on that chain could never see it. Neither the build nor the
lint config catches an unbound identifier in a .jsx file: eslint.config.js
spreads pluginJs.configs.recommended and then declares its own rules
object, which replaces the spread rules wholesale, so no-undef has never
run. A repo-wide sweep with it enabled found this was the only instance.
Pre-existing; found while auditing the coordinate changes.
Dead code and stale docs
ALL_DESTINATIONS (declared, never read; its comment claimed it was what
the mobile sheet renders). --dai-panel-width (declared twice, read
nowhere) and the max-width:1279px block that only set it. AdminLayout's
header comment said "three grouped menus (Fleet Ops, Reports, Settings)"
when there are two, and Settings is a section in the account menu — that
difference in routing is what caused the mobile gap above.
VERIFIED
878 tests across 26 suites, build clean, no new lint problems. The tab
strip, dock width, chip wrapping and navbar sizing were measured in a real
browser against the shipped stylesheet. deriveVisualData is module-private,
so it was extracted and driven through 16 logic checks.
NOT VERIFIED
/doormile/home has never been rendered in a browser, and the responsive
pass covers the assistant panel only — no page has been viewed at any
breakpoint. Both need a signed-in session. DataTable carries its own
overflow-x-auto and only two fixed widths above 390px exist in src/pages,
but that is grep, not eyes.
STILL OPEN
customerAppBookings.js lost its customerstatus/customerstage grouping in
49ee0c5 and has not been restored, so the Bookings tabs still read only the
operational status. The Status column (Bookings.jsx:98, :415) was never
stage-aware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
826 lines
30 KiB
JavaScript
826 lines
30 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import dayjs from 'dayjs';
|
|
import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react';
|
|
import {
|
|
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, Input,
|
|
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
|
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
|
|
} from '@/components/ds';
|
|
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
|
import {
|
|
useBooking, useCancelDelivery, useChangeDeliveryRider, useDeliveries,
|
|
useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants,
|
|
useUpdateDeliveryStatus,
|
|
} from '@/lib/doormileHooks';
|
|
import { useZone } from '@/lib/ZoneContext';
|
|
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
|
|
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
|
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
|
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
|
import { summariseRouting } from '@/lib/routingSummary';
|
|
|
|
/**
|
|
* Deliveries — orders that have moved past merely being created.
|
|
*
|
|
* The rows are built from `/admin/bookings` filtered to those with a rider or a
|
|
* consignment, joined against customers, riders, clients and consignments; see
|
|
* `fetchDeliveries` for why the consignments endpoint cannot be the source on
|
|
* its own, and for which columns are deliberately left blank rather than filled
|
|
* with numbers the API never returned.
|
|
*
|
|
* A row's status comes from its consignment when it has one. A booking freezes
|
|
* at `Converted_To_Consignment` the moment it is picked up, so a row can
|
|
* legitimately read "Active" while the booking still says otherwise — which is
|
|
* why the table can say where the value came from.
|
|
*/
|
|
|
|
/* `other` is not declared here — it is appended only when rows actually land
|
|
in it, so the strip never carries a permanently empty tab. */
|
|
const STATUS_TABS = [
|
|
{ value: 'all', label: 'All', countKey: 'total' },
|
|
{ value: 'pending', label: 'Pending', countKey: 'uncoveredLength' },
|
|
{ value: 'accepted', label: 'Accepted', countKey: 'assignedLength' },
|
|
{ value: 'arrived', label: 'Arrived', countKey: 'arrivedLength' },
|
|
{ value: 'picked', label: 'Picked', countKey: 'pickedLength' },
|
|
{ value: 'active', label: 'Active', countKey: 'activeLength' },
|
|
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
|
|
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
|
|
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' },
|
|
];
|
|
|
|
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
|
|
|
|
const BATCH_OPTIONS = [
|
|
{ id: 'all', label: 'All batches', range: 'Across the day' },
|
|
...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })),
|
|
];
|
|
|
|
const ALL = 'all';
|
|
|
|
/** The statuses this console can actually write to a consignment. */
|
|
const SETTABLE_STATUSES = [
|
|
{ value: 'active', label: 'Out for delivery' },
|
|
{ value: 'delivered', label: 'Delivered' },
|
|
{ value: 'cancelled', label: 'Cancelled' },
|
|
];
|
|
|
|
export default function Deliveries() {
|
|
const todayStr = dayjs().format('YYYY-MM-DD');
|
|
const [selectedDate, setSelectedDate] = useState(todayStr);
|
|
const [tab, setTab] = useState('all');
|
|
const [batch, setBatch] = useState('all');
|
|
const [search, setSearch] = useState('');
|
|
const debouncedSearch = useDebouncedValue(search);
|
|
const [page, setPage] = useState(1);
|
|
|
|
const [tenantId, setTenantId] = useState(ALL);
|
|
const [riderId, setRiderId] = useState(ALL);
|
|
|
|
const [detailRow, setDetailRow] = useState(null);
|
|
const [riderRow, setRiderRow] = useState(null);
|
|
const [selectedRider, setSelectedRider] = useState('');
|
|
const [statusRow, setStatusRow] = useState(null);
|
|
const [nextStatus, setNextStatus] = useState('delivered');
|
|
const [cancelRow, setCancelRow] = useState(null);
|
|
const [cancelReason, setCancelReason] = useState('');
|
|
|
|
const dateParams = useMemo(() => {
|
|
if (!selectedDate || selectedDate === 'all') return { from: '', to: '' };
|
|
if (typeof selectedDate === 'string') return { from: selectedDate, to: selectedDate };
|
|
return { from: selectedDate.from || '', to: selectedDate.to || '' };
|
|
}, [selectedDate]);
|
|
|
|
const { data, isLoading, isFetching } = useDeliveries({
|
|
page,
|
|
pageSize: 100,
|
|
from: dateParams.from,
|
|
to: dateParams.to,
|
|
});
|
|
const { data: counts } = useDeliveryCounts();
|
|
const { data: riders = [] } = useRiderOptions();
|
|
const { data: tenants = [] } = useTenants();
|
|
|
|
const changeRider = useChangeDeliveryRider();
|
|
const updateStatus = useUpdateDeliveryStatus();
|
|
const cancelDelivery = useCancelDelivery();
|
|
const notifyMiler = useNotifyMiler();
|
|
const { selectedZone, isAllZones, matchesZone } = useZone();
|
|
|
|
const riderLookup = useMemo(() => {
|
|
const map = new Map();
|
|
(riders || []).forEach((r) => {
|
|
const name =
|
|
r.displayname ||
|
|
[r.firstname, r.lastname].filter(Boolean).join(' ').trim() ||
|
|
r.name ||
|
|
r.authname ||
|
|
(r.label ? r.label.split('|')[0].trim() : '');
|
|
const phone = r.contactno || r.phone || '';
|
|
if (r.userid != null) map.set(String(r.userid), { name, phone });
|
|
if (r.milerid != null) map.set(String(r.milerid), { name, phone });
|
|
if (r.milerprofileid != null) map.set(String(r.milerprofileid), { name, phone });
|
|
if (r.id != null) map.set(String(r.id), { name, phone });
|
|
if (r.value != null) map.set(String(r.value), { name, phone });
|
|
});
|
|
return map;
|
|
}, [riders]);
|
|
|
|
const allRows = useMemo(() => {
|
|
return [...(data?.rows || [])].sort((a, b) => {
|
|
const ta = parseDoormileTimestamp(a.orderdate || a.assigntime).valueOf() || 0;
|
|
const tb = parseDoormileTimestamp(b.orderdate || b.assigntime).valueOf() || 0;
|
|
return tb - ta;
|
|
});
|
|
}, [data?.rows]);
|
|
|
|
/* Zone, Batch, client and rider first, then status, then search — the counts under
|
|
the tabs have to describe the slice the operator has chosen, not the whole
|
|
day, or a tab reads 6 and shows 2. */
|
|
const batchRows = useMemo(
|
|
() =>
|
|
allRows
|
|
.filter(matchesZone)
|
|
.filter((row) => batch === 'all' || getRowBatchId(row) === batch)
|
|
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
|
|
.filter((row) => riderId === ALL || String(row.userid) === riderId),
|
|
[allRows, matchesZone, batch, tenantId, riderId]
|
|
);
|
|
|
|
const unmapped = useMemo(
|
|
() => batchRows.filter((row) => !KNOWN_STATUSES.includes(String(row.orderstatus || '').toLowerCase())),
|
|
[batchRows]
|
|
);
|
|
|
|
const tabs = useMemo(() => {
|
|
const base = STATUS_TABS.map((entry) => ({
|
|
value: entry.value,
|
|
label: entry.label,
|
|
count:
|
|
entry.value === 'all'
|
|
? batchRows.length
|
|
: batchRows.filter((row) => String(row.orderstatus || '').toLowerCase() === entry.value).length,
|
|
}));
|
|
/* A row whose status matches no tab used to be counted nowhere and shown
|
|
nowhere. It gets its own tab, but only when one actually exists. */
|
|
if (unmapped.length) base.push({ value: 'other', label: 'Other', count: unmapped.length });
|
|
return base;
|
|
}, [batchRows, unmapped]);
|
|
|
|
const rows = useMemo(() => {
|
|
let scoped = batchRows;
|
|
if (tab === 'other') scoped = unmapped;
|
|
else if (tab !== 'all') {
|
|
scoped = batchRows.filter((row) => {
|
|
const status = String(row.orderstatus || '').toLowerCase();
|
|
return tab === 'cancelled' ? status === 'cancelled' || status === 'canceled' : status === tab;
|
|
});
|
|
}
|
|
|
|
return scoped.filter((row) =>
|
|
matchesQuery(
|
|
row,
|
|
[
|
|
'orderid',
|
|
'bookingno',
|
|
'bookingid',
|
|
'consignmentid',
|
|
'deliverycustomer',
|
|
'ridername',
|
|
'pickupaddress',
|
|
'deliveryaddress',
|
|
'tenantname',
|
|
],
|
|
debouncedSearch
|
|
)
|
|
);
|
|
}, [batchRows, tab, unmapped, debouncedSearch]);
|
|
|
|
/* Whether anything is narrowing the list — drives the empty state's wording
|
|
and whether Clear filters is offered. It has to name every filter, or the
|
|
button appears and then visibly fails to clear one of them. */
|
|
const isFiltered =
|
|
Boolean(debouncedSearch) || batch !== 'all' || tenantId !== ALL || riderId !== ALL;
|
|
|
|
/* A new filter re-scopes the list, so page 1 is the only page that still
|
|
means anything. */
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [tab, batch, tenantId, riderId, debouncedSearch, dateParams.from, dateParams.to]);
|
|
|
|
const columns = useMemo(
|
|
() => [
|
|
{
|
|
key: 'orderid',
|
|
header: 'Order & client',
|
|
sortable: true,
|
|
width: '130px',
|
|
cell: (row) => (
|
|
<div className="min-w-0">
|
|
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.orderid)}</p>
|
|
<p className="truncate text-caption text-ink-3">{orDash(row.tenantname)}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'route',
|
|
header: 'Route (pickup → drop)',
|
|
width: '260px',
|
|
className: 'max-w-[260px]',
|
|
sortValue: (row) => row.pickupaddress || '',
|
|
cell: (row) => (
|
|
<div className="max-w-[260px] min-w-0 overflow-hidden">
|
|
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
|
<span className="font-medium text-ink-1">Pickup</span>
|
|
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
|
|
</p>
|
|
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
|
<span className="font-medium text-ink-2">{orDash(row.deliverycustomer)}</span>
|
|
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'ridername',
|
|
header: 'Miler',
|
|
sortable: true,
|
|
width: '140px',
|
|
cell: (row) => {
|
|
const riderKey = String(row.userid || row.milerprofileid || '');
|
|
const info = riderLookup.get(riderKey);
|
|
const name =
|
|
(info?.name && !info.name.startsWith('Miler #') && !info.name.startsWith('Rider #'))
|
|
? info.name
|
|
: (row.ridername && !row.ridername.startsWith('Miler #') && !row.ridername.startsWith('Rider #'))
|
|
? row.ridername
|
|
: (info?.name || row.ridername || (row.userid ? `Miler #${row.userid}` : '—'));
|
|
const contact = info?.phone || row.ridercontact;
|
|
|
|
return (
|
|
<div className="min-w-0">
|
|
<p className="truncate text-body-sm font-semibold text-ink-1">{name}</p>
|
|
<p className="text-caption text-ink-3">{orDash(contact)}</p>
|
|
</div>
|
|
);
|
|
},
|
|
hideBelow: 'md',
|
|
},
|
|
{
|
|
key: 'eta',
|
|
header: 'ETA & distance',
|
|
width: '120px',
|
|
sortValue: (row) => row.expecteddeliverytime || '',
|
|
cell: (row) => (
|
|
<div className="min-w-0">
|
|
<p className="text-body-sm text-ink-2">
|
|
{formatDoormileTimestamp(row.expecteddeliverytime, 'hh:mm A')}
|
|
</p>
|
|
<p className="text-caption text-ink-3">{formatKm(row.kms)}</p>
|
|
</div>
|
|
),
|
|
hideBelow: 'lg',
|
|
},
|
|
{
|
|
key: 'deliverycharges',
|
|
header: 'Amount',
|
|
align: 'right',
|
|
width: '85px',
|
|
sortValue: (row) => Number(row.deliverycharges) || 0,
|
|
accessor: (row) => currency(row.deliverycharges),
|
|
hideBelow: 'lg',
|
|
},
|
|
{
|
|
key: 'orderstatus',
|
|
header: 'Status',
|
|
width: '120px',
|
|
sortable: true,
|
|
cell: (row) => (
|
|
<div className="flex flex-col items-start gap-0.5">
|
|
<StatusBadge status={row.orderstatus} dot size="sm" />
|
|
{row.statusfromconsignment ? (
|
|
<span className="text-[10px] text-ink-4">from consignment</span>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: 'Actions',
|
|
align: 'right',
|
|
width: '130px',
|
|
cell: (row) => (
|
|
<div className="flex justify-end gap-1">
|
|
<IconButton
|
|
label="Order details"
|
|
icon={PackageSearch}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDetailRow(row)}
|
|
/>
|
|
|
|
{row.orderstatus !== 'delivered' && row.milerprofileid ? (
|
|
<IconButton
|
|
label="Notify rider"
|
|
icon={Bell}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
notifyMiler.mutate({
|
|
milerProfileId: row.milerprofileid,
|
|
title: 'DoormileXpress',
|
|
message: 'Orders have been placed for delivery. Kindly accept and process deliveries.',
|
|
})
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
{['pending', 'accepted', 'arrived'].includes(row.orderstatus) ? (
|
|
<IconButton
|
|
label="Change miler"
|
|
icon={Bike}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setRiderRow(row);
|
|
setSelectedRider('');
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<IconButton
|
|
label="Update status"
|
|
icon={Truck}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setStatusRow(row);
|
|
setNextStatus('delivered');
|
|
}}
|
|
/>
|
|
|
|
{!['delivered', 'cancelled', 'canceled'].includes(String(row.orderstatus || '').toLowerCase()) ? (
|
|
<IconButton
|
|
label="Cancel delivery"
|
|
icon={Ban}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setCancelRow(row);
|
|
setCancelReason('');
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
],
|
|
[notifyMiler]
|
|
);
|
|
|
|
const exportColumns = [
|
|
{ key: 'orderid', header: 'Order' },
|
|
{ key: 'tenantname', header: 'Client' },
|
|
{ key: 'deliverycustomer', header: 'Customer' },
|
|
{ key: 'ridername', header: 'Miler' },
|
|
{ key: 'orderstatus', header: 'Status' },
|
|
{ key: 'kms', header: 'Km' },
|
|
{ key: 'deliverycharges', header: 'Amount' },
|
|
{ key: 'orderdate', header: 'Created' },
|
|
];
|
|
|
|
return (
|
|
<Stack space="lg">
|
|
<PageHeader
|
|
icon={Truck}
|
|
title="Deliveries"
|
|
subtitle={`${batchRows.length} dispatched · ${!dateParams.from && !dateParams.to
|
|
? 'All dates'
|
|
: selectedDate === todayStr
|
|
? 'Today'
|
|
: selectedDate === dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
|
? 'Yesterday'
|
|
: dateParams.from === dateParams.to
|
|
? dayjs(dateParams.from).format('DD MMM YYYY')
|
|
: `${dayjs(dateParams.from).format('DD MMM')} → ${dayjs(dateParams.to).format('DD MMM YYYY')}`
|
|
}`}
|
|
actions={
|
|
<DatePicker
|
|
value={selectedDate}
|
|
onChange={(d) => {
|
|
if (d) {
|
|
const s = dayjs(d).format('YYYY-MM-DD');
|
|
setDateParams({ from: s, to: s });
|
|
setSelectedDate(s);
|
|
}
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<Stack space="sm">
|
|
<Tabs tabs={tabs} value={tab} onChange={setTab} />
|
|
|
|
<ListToolbar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
placeholder="Search by order, client, miler, customer or address"
|
|
count={{ shown: rows.length, total: batchRows.length, noun: 'deliveries' }}
|
|
actions={
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => exportRows(rows, exportColumns, `deliveries-${tab}-${dateParams.from || 'all'}`)}
|
|
disabled={rows.length === 0}
|
|
>
|
|
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
|
</Button>
|
|
}
|
|
>
|
|
<ZoneSelector className="w-full sm:w-auto" />
|
|
<Select value={batch} onValueChange={setBatch}>
|
|
<SelectTrigger className="w-full sm:w-52">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{BATCH_OPTIONS.map((option) => (
|
|
<SelectItem key={option.id} value={option.id}>
|
|
{option.label} · {option.range}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={tenantId} onValueChange={setTenantId}>
|
|
<SelectTrigger className="w-full sm:w-48">
|
|
<SelectValue placeholder="All clients" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL}>All clients</SelectItem>
|
|
{(tenants || []).map((tenant) => (
|
|
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
|
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={riderId} onValueChange={setRiderId}>
|
|
<SelectTrigger className="w-full sm:w-48">
|
|
<SelectValue placeholder="All milers" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL}>All milers</SelectItem>
|
|
{(riders || []).map((rider) => (
|
|
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
|
{rider.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</ListToolbar>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
getRowId={(row) => row.orderheaderid}
|
|
loading={isLoading && !data}
|
|
refreshing={isFetching && Boolean(data)}
|
|
pageSize={25}
|
|
defaultSort={{ key: 'orderid', direction: 'desc' }}
|
|
isFiltered={isFiltered}
|
|
onClearFilters={() => {
|
|
setSearch('');
|
|
setBatch('all');
|
|
setTenantId(ALL);
|
|
setRiderId(ALL);
|
|
}}
|
|
emptyState={
|
|
<EmptyState
|
|
icon={Truck}
|
|
title={isFiltered ? 'No matching deliveries' : 'No deliveries found'}
|
|
description={
|
|
isFiltered
|
|
? 'Try clearing search or filters to see more orders.'
|
|
: 'Dispatch orders from the Orders page to populate deliveries.'
|
|
}
|
|
action={
|
|
isFiltered
|
|
? {
|
|
label: 'Clear filters',
|
|
onClick: () => {
|
|
setSearch('');
|
|
setBatch('all');
|
|
setTenantId(ALL);
|
|
setRiderId(ALL);
|
|
},
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
</Stack>
|
|
|
|
<Modal
|
|
open={Boolean(riderRow)}
|
|
onOpenChange={(open) => !open && setRiderRow(null)}
|
|
title="Change miler"
|
|
description={riderRow ? `Order ${riderRow.orderid}` : undefined}
|
|
icon={Bike}
|
|
busy={changeRider.isPending}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setRiderRow(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
const rider = (riders || []).find((entry) => String(entry.userid) === selectedRider);
|
|
if (!rider) return;
|
|
changeRider.mutate(
|
|
{ rider, row: riderRow },
|
|
{ onSuccess: (res) => res?.success !== false && setRiderRow(null) }
|
|
);
|
|
}}
|
|
disabled={!selectedRider || changeRider.isPending}
|
|
>
|
|
Reassign
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<Field label="Miler">
|
|
<Select value={selectedRider} onValueChange={setSelectedRider}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose a miler" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(riders || []).map((rider) => (
|
|
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
|
{rider.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={Boolean(statusRow)}
|
|
onOpenChange={(open) => !open && setStatusRow(null)}
|
|
title="Update status"
|
|
description={statusRow ? `Order ${statusRow.orderid}` : undefined}
|
|
icon={Truck}
|
|
busy={updateStatus.isPending}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setStatusRow(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() =>
|
|
updateStatus.mutate(
|
|
{ ...statusRow, orderstatus: nextStatus },
|
|
{ onSuccess: (res) => res?.success !== false && setStatusRow(null) }
|
|
)
|
|
}
|
|
disabled={updateStatus.isPending || !statusRow?.consignmentid}
|
|
>
|
|
Update status
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<Stack space="default">
|
|
{statusRow && !statusRow.consignmentid ? (
|
|
/* The write is consignment-scoped and only exists once the rider
|
|
marks pickup complete. Blocking here with the reason beats
|
|
letting the submit silently do nothing. */
|
|
<Alert tone="warning" title="Not picked up yet">
|
|
Status can only be updated once this order becomes a consignment — that happens when the rider completes
|
|
pickup.
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Field
|
|
label="New status"
|
|
hint="Pending, Accepted, Arrived, Picked and Skipped have no equivalent on the consignment API and cannot be set from here."
|
|
>
|
|
<Select value={nextStatus} onValueChange={setNextStatus} disabled={!statusRow?.consignmentid}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SETTABLE_STATUSES.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={Boolean(cancelRow)}
|
|
onOpenChange={(open) => !open && setCancelRow(null)}
|
|
title="Cancel this delivery"
|
|
description={cancelRow ? `Order ${cancelRow.orderid}` : undefined}
|
|
busy={cancelDelivery.isPending}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setCancelRow(null)}>
|
|
Keep it
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() =>
|
|
cancelDelivery.mutate(
|
|
{ row: cancelRow, reason: cancelReason },
|
|
{ onSettled: () => setCancelRow(null) }
|
|
)
|
|
}
|
|
loading={cancelDelivery.isPending}
|
|
>
|
|
Cancel delivery
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<Field label="Reason">
|
|
<Textarea rows={3} value={cancelReason} onChange={(e) => setCancelReason(e.target.value)} />
|
|
</Field>
|
|
</Modal>
|
|
|
|
<OrderDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* How this booking was routed, and on what.
|
|
*
|
|
* Backend decides the route; this only reports the decision and the inputs it
|
|
* was made from, so support can answer "why does the rider's screen say hand
|
|
* over at a base instead of deliver?" without reconstructing a pincode rule by
|
|
* hand.
|
|
*
|
|
* Before pickup the decision has not been taken — `decided` is false and what is
|
|
* shown is a projection from the captured from/to, labelled as such. A number
|
|
* presented as a decision when it is only a forecast is worse than no number.
|
|
*
|
|
* Wording note: the wire says hub, operators and riders say base. The
|
|
* translation happens here, at the edge, and never travels back up.
|
|
*/
|
|
function RoutingSummary({ routing }) {
|
|
const s = summariseRouting(routing);
|
|
if (!s) return null;
|
|
|
|
return (
|
|
<div>
|
|
<p className="mb-2 text-overline uppercase text-ink-4">Routing</p>
|
|
<Surface variant="subtle" padding="sm" radius="lg">
|
|
<Stack space="xs">
|
|
<p className="text-body-sm text-ink-1">{s.reason}</p>
|
|
<p className="text-caption text-ink-3">{s.route}</p>
|
|
|
|
<dl className="grid grid-cols-2 gap-3 pt-1">
|
|
{[
|
|
['Collected from', pickupSourceTypeLabel(routing.pickup_source_type)],
|
|
['Parcel state', s.consignmentState],
|
|
['Next step', s.nextAction],
|
|
['Base', s.base || '—'],
|
|
].map(([label, value]) => (
|
|
<div key={label}>
|
|
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
|
|
<dd className="text-body-sm text-ink-1">{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
{s.baseAddress ? <p className="text-caption text-ink-3">{s.baseAddress}</p> : null}
|
|
{s.inwardedAt ? (
|
|
<p className="text-caption text-ink-3">
|
|
Received at the base {formatDoormileTimestamp(s.inwardedAt)}
|
|
</p>
|
|
) : null}
|
|
</Stack>
|
|
</Surface>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* What is actually in the parcel.
|
|
*
|
|
* The table row is built from the bulk bookings feed, which carries no parcel
|
|
* contents — so this fetches the single booking on open rather than showing a
|
|
* panel of blanks. A parcel has no quantity field: N items arrive as N entries,
|
|
* which is why the count here is the length of the list.
|
|
*/
|
|
function OrderDetailDrawer({ row, onClose }) {
|
|
const { data: booking, isLoading } = useBooking(row?.orderheaderid);
|
|
const parcels = booking?.parcels || [];
|
|
|
|
return (
|
|
<Drawer
|
|
open={Boolean(row)}
|
|
onOpenChange={(next) => !next && onClose()}
|
|
title={`Order ${row?.orderid ?? ''}`}
|
|
description={row?.tenantname || undefined}
|
|
icon={PackageSearch}
|
|
size="lg"
|
|
>
|
|
{isLoading ? (
|
|
<p className="text-body-sm text-ink-3">Loading order…</p>
|
|
) : (
|
|
<Stack space="default">
|
|
<Surface variant="subtle" padding="sm" radius="lg">
|
|
<dl className="grid grid-cols-2 gap-3">
|
|
{[
|
|
['Status', row ? <StatusBadge status={row.consignmentstatus || row.orderstatus} size="sm" /> : '—'],
|
|
['Rider', orDash(row?.ridername)],
|
|
['Customer', orDash(row?.deliverycustomer)],
|
|
['Contact', orDash(row?.deliverycontactno)],
|
|
['Charge', currency(row?.deliverycharges)],
|
|
['Distance', formatKm(row?.kms)],
|
|
['Placed', formatDoormileTimestamp(row?.orderdate)],
|
|
['Expected', formatDoormileTimestamp(row?.expecteddeliverytime)],
|
|
].map(([label, value]) => (
|
|
<div key={label}>
|
|
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
|
|
<dd className="text-body-sm text-ink-1">{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</Surface>
|
|
|
|
<div>
|
|
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
|
|
<p className="text-body-sm text-ink-2">
|
|
{orDash(booking?.routing?.from_address || row?.pickupaddress)}
|
|
</p>
|
|
{booking?.routing ? (
|
|
<p className="text-caption text-ink-3">
|
|
{[
|
|
pickupSourceTypeLabel(booking.routing.pickup_source_type),
|
|
booking.routing.pickup_source_name,
|
|
booking.routing.from_pincode
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
|
|
<p className="text-body-sm text-ink-2">
|
|
{orDash(booking?.routing?.to_address || row?.deliveryaddress)}
|
|
</p>
|
|
{booking?.routing?.destination_pincode ? (
|
|
<p className="text-caption text-ink-3">{booking.routing.destination_pincode}</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Why this parcel is going where it is going.
|
|
A rider rings asking why his screen says hand over at a base
|
|
instead of deliver to the customer; without this, answering meant
|
|
reconstructing the routing rule from two pincodes by hand. The
|
|
backend decides the route and reports it here with the inputs it
|
|
used, so the answer is a lookup. */}
|
|
{booking?.routing ? <RoutingSummary routing={booking.routing} /> : null}
|
|
|
|
<div>
|
|
<p className="mb-2 text-overline uppercase text-ink-4">
|
|
Parcels {parcels.length ? `· ${parcels.length}` : ''}
|
|
</p>
|
|
{parcels.length === 0 ? (
|
|
<p className="text-body-sm text-ink-3">No parcel detail recorded on this order.</p>
|
|
) : (
|
|
<Stack space="xs">
|
|
{parcels.map((parcel, index) => (
|
|
<Surface key={index} variant="subtle" padding="sm" radius="lg">
|
|
<p className="text-body-sm font-medium text-ink-1">{orDash(parcel.itemcategory)}</p>
|
|
<p className="text-caption text-ink-3">
|
|
{orDash(parcel.itemdescription)}
|
|
{parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
|
|
</p>
|
|
</Surface>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</div>
|
|
|
|
{row?.notes ? (
|
|
<div>
|
|
<p className="mb-2 text-overline uppercase text-ink-4">Notes</p>
|
|
<p className="text-body-sm text-ink-2">{row.notes}</p>
|
|
</div>
|
|
) : null}
|
|
</Stack>
|
|
)}
|
|
</Drawer>
|
|
);
|
|
}
|