Fix assistant assign flow, cache staleness, and sweep KPI gloss lines

Assistant — assign:
- Order step is a dropdown, not a typed booking number. Exact bookingid /
  bookingno matches now win across the whole list before any substring
  fallback; row order previously decided the winner, so a short numeric id
  could substring-match a different booking than the one on screen.
- Rider step resolves by id or name, so answering by typing no longer
  leaves __rider undefined and throws inside executeAssign.
- notified now reflects whether the push actually went out. It was
  Boolean(rider.milerprofileid) — "has an id, assume they were told" — so a
  notify returning 400 still reported "The rider has been notified."
- Batch assign removed. /hub/bookings/batch-assign is a hub-console
  endpoint; an admin JWT is refused (403 on an empty body, so the gate is
  on identity). It read as the fast path and could never run.

Assistant — repeat runs:
- Bulk create response is read at data.results, the shape the live endpoint
  returns. Only the two flatter shapes were checked, so every bookingid was
  discarded while the count came out right by accident.
- Repeat carries each order's previous rider and re-assigns on create, one
  admin call per order, one push per rider. Confirm cards say so.
- Message ids are clock-seeded and healed on load; the counter restarted at
  1 each session and overwrote itself, giving duplicate React keys.
- Successful writes invalidate the query cache, so the page behind the panel
  stops serving rows cached before the write.

Date range picker:
- Fields seed from the range already in force instead of opening blank on a
  filtered table.
- Start field takes max={end}; only the end had a bound, so an inverted
  range could be emitted and filtered to nothing.

UI:
- Deliveries filters moved outside the table card.
- Side nav keeps one hairline; the border and box-shadow stacked into a
  heavy edge.
- KPI gloss lines removed across orders, riders, tenants, customers, hubs,
  vehicles, exceptions, app users, orders summary/details, riders summary
  and profitability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:51:32 +05:30
parent 3b239b949a
commit 414ba6dbf2
38 changed files with 1996 additions and 1049 deletions

View File

@@ -15,10 +15,6 @@ import {
MdCheckCircle,
MdCancel,
MdInventory2,
MdOutlineLocalShipping,
MdOutlinePendingActions,
MdOutlineCheckCircle,
MdOutlineCancel,
MdKeyboardArrowDown,
MdKeyboardArrowUp,
MdArrowDownward,
@@ -89,11 +85,10 @@ import { OpenToast } from 'components/third-party/OpenToast';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import StatusTabs from 'components/nearle_components/StatusTabs';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
@@ -127,14 +122,6 @@ const STATUS_TABS = [
{ status: 'cancelled', countKey: 'cancelLength' }
];
// KPI palette + icons — mirrors the four cards across the top of the page.
const KPI_META = [
{ key: 'created', label: 'Created Orders', color: '#0ea5e9', icon: MdOutlineLocalShipping },
{ key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdOutlinePendingActions },
{ key: 'delivered', label: 'Delivered Orders', color: '#10b981', icon: MdOutlineCheckCircle },
{ key: 'cancelled', label: 'Cancelled Orders', color: '#ef4444', icon: MdOutlineCancel }
];
// Batches mirror the dispatch page's slot definitions so an operator who
// segments the day there sees the same buckets here. Hours are 24h, half-open
// [startHour, endHour) — a delivery at exactly endHour falls into the *next*
@@ -479,8 +466,7 @@ const Deliveries = () => {
setChangeDialogOpen(false);
if (res.success) {
logger.info(
`Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${
selectedRider?.displayname || selectedRider?.authname
`Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname
}`
);
opentoast('Rider Changed Successfully', 'success');
@@ -773,7 +759,7 @@ const Deliveries = () => {
},
{
id: 'serial',
size: 56,
size: 54,
minSize: 48,
enableSorting: false,
meta: { label: '#' },
@@ -792,18 +778,13 @@ const Deliveries = () => {
id: 'status',
accessorKey: 'orderstatus',
size: 130,
minSize: 100,
minSize: 110,
meta: { label: 'Status' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Status
</Text>
),
// The badge explains itself when the value did not come from the
// booking. Without this an operator comparing the table against
// GET /admin/bookings sees "Active" next to
// `"status": "Converted_To_Consignment"` and reasonably assumes the
// page is wrong — it isn't; the booking simply stops moving at pickup.
cell: ({ getValue, row }) =>
row.original.statusfromconsignment ? (
<Tooltip
@@ -818,150 +799,106 @@ const Deliveries = () => {
)
},
{
id: 'tenant',
accessorKey: 'tenantname',
size: 170,
minSize: 130,
meta: { label: 'Tenant' },
id: 'orderTenant',
accessorFn: (row) => `${row.orderid} ${row.tenantname}`,
size: 260,
minSize: 210,
meta: { label: 'Order & Tenant' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Tenant
Order & Tenant
</Text>
),
cell: ({ row }) => (
<Tooltip content={row.original.tenantadress || ''}>
<VStack gap={0} padding={0}>
<Text weight="semibold" maxLines={1}>
{row.original.tenantname}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.tenantsuburb}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.applocation}
</Text>
</VStack>
</Tooltip>
)
},
{
id: 'orderLocation',
accessorFn: (row) => row.orderid,
size: 240,
minSize: 180,
enableSorting: false,
meta: { label: 'Order / Location' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Order / Location
</Text>
),
// Order and delivery are stacked, not side by side. They used to be two
// VStacks inside an HStack, and flex items default to `min-width: auto`
// — so the order id (`DM-BK-BC87D8B0-30828`, unbreakable and wider than
// half the column) refused to shrink and crushed the delivery stack to
// zero width. A zero-width cell doesn't hide its text, it wraps it one
// character per line, which is what rendered as a vertical strip of
// letters. Two 20-character ids never fitted a 220px column; labelling
// them and giving each its own line is what actually fixes it, and the
// bare "-" separator between them conveyed nothing.
cell: ({ row }) => {
const r = row.original;
// express-console-api.md: Doormile timestamps are IST wall-clock with
// no timezone marker. A Z-suffixed response reinterpreted as local
// time shifts by 5:30 — showing the wrong time and sometimes the
// wrong day. parseDoormileTimestamp strips that, matching
// getRowBatchId above.
const ordered = parseDoormileTimestamp(r.orderdate);
const delivered = parseDoormileTimestamp(r.deliverydate);
return (
<VStack gap={0} padding={0} minWidth={0}>
<Tooltip content="Location Name-Suburb">
<Text maxLines={1}>{r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname}</Text>
<VStack gap={0.5} padding={0} minWidth={0}>
<Tooltip content={`Order ID: ${r.orderid} · Hub: ${r.locationname || '—'}`}>
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary, letterSpacing: -0.2 }}>
{r.orderid}
</Text>
</Tooltip>
<Tooltip content="Order Id">
<Text maxLines={1}>{r.orderid}</Text>
</Tooltip>
<Text type="supporting" color="secondary">
Ordered {ordered.format('DD/MM/YYYY · hh:mm A')}
</Text>
<Tooltip content="Delivery Id">
<Text maxLines={1}>{r.deliveryid}</Text>
</Tooltip>
<Text type="supporting" color="secondary">
Delivery {delivered.format('DD/MM/YYYY · hh:mm A')}
</Text>
<HStack gap={1} vAlign="center" minWidth={0}>
<Tooltip content={r.tenantadress ? `${r.tenantname} · ${r.tenantadress}` : r.tenantname}>
<Text weight="medium" color="secondary" maxLines={1} style={{ fontSize: 12 }}>
{r.tenantname}
</Text>
</Tooltip>
<Text type="supporting" color="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
· {ordered.isValid() ? ordered.format('hh:mm A') : '—'}
</Text>
</HStack>
</VStack>
);
}
},
{
id: 'pickup',
accessorKey: 'pickupcustomer',
size: 190,
minSize: 140,
meta: { label: 'Pickup' },
id: 'route',
accessorFn: (row) => `${row.pickupcustomer} ${row.deliverycustomer}`,
size: 320,
minSize: 260,
meta: { label: 'Route (Pickup → Drop)' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Pickup
Route (Pickup → Drop)
</Text>
),
// pickup — capped to the same maxWidth as drop below, with CSS ellipsis
// truncation, so one long address can't stretch this column wider than
// its neighbour. row.pickuplocation is just the full raw address (see
// fetchDeliveries in api.js), not a pre-shortened name, so the old
// `.slice(0, 14)` fallback never actually ran. Full text still
// available via the tooltip.
cell: ({ row }) => (
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}>
{row.original.pickupcustomer}
</Text>
<Text type="supporting" color="secondary">
{row.original.pickupcontactno}
</Text>
<Tooltip content={row.original.Pickupaddress || ''}>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.pickuplocation || row.original.Pickupaddress || '—'}
</Text>
cell: ({ row }) => {
const r = row.original;
const pickupAddr = r.pickupsuburb || r.pickuplocation || r.Pickupaddress || '';
const dropAddr = r.deliverysuburb || r.deliverylocation || r.deliveryaddress || '';
const routeTooltip = (
<div style={{ textAlign: 'left', minWidth: 200, maxWidth: 300, whiteSpace: 'normal', color: '#fff' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', marginBottom: '8px' }}>
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontWeight: 'bold', fontSize: '12px' }}>Pickup: {r.pickupcustomer || 'Pickup'} {r.pickupcontactno ? `(${r.pickupcontactno})` : ''}</span>
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.Pickupaddress || r.pickuplocation || 'No address'}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
<img src="/drop-icon.png" alt="Drop" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontWeight: 'bold', fontSize: '12px' }}>Drop: {r.deliverycustomer || 'Drop'} {r.deliverycontactno ? `(${r.deliverycontactno})` : ''}</span>
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.deliveryaddress || r.deliverylocation || 'No address'}</span>
</div>
</div>
</div>
);
return (
<Tooltip content={routeTooltip}>
<VStack gap={0.5} padding={0} minWidth={0} style={{ cursor: 'pointer' }}>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 16, height: 16, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
{r.pickupcustomer || 'Pickup'}
</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{pickupAddr ? `· ${pickupAddr}` : ''}
</Text>
</HStack>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/drop-icon.png" alt="Drop" style={{ width: 17, height: 17, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
{r.deliverycustomer || 'Drop'}
</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{dropAddr ? `· ${dropAddr}` : ''}
</Text>
</HStack>
</VStack>
</Tooltip>
</VStack>
)
},
{
id: 'drop',
accessorKey: 'deliverycustomer',
size: 190,
minSize: 140,
meta: { label: 'Drop' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Drop
</Text>
),
cell: ({ row }) => (
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}>
{row.original.deliverycustomer}
</Text>
<Text type="supporting" color="secondary">
{row.original.deliverycontactno}
</Text>
<Tooltip content={row.original.deliveryaddress || ''}>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.deliverylocation || row.original.deliveryaddress || '—'}
</Text>
</Tooltip>
</VStack>
)
);
}
},
{
id: 'rider',
accessorKey: 'ridername',
size: 180,
minSize: 140,
size: 190,
minSize: 150,
sortUndefined: 'last',
meta: { label: 'Rider' },
header: () => (
@@ -972,89 +909,92 @@ const Deliveries = () => {
cell: ({ row }) => {
const r = row.original;
return r.ridername ? (
<HStack gap={1.5} vAlign="center">
<HStack gap={1.5} vAlign="center" minWidth={0}>
<AccentAvatar color="#8b5cf6" size={28}>
<MdDirectionsBike size={14} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}>
{r.ridername}
</Text>
<Text type="supporting" color="secondary">
ID #{r.userid} · {r.ridercontact || '—'}
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
ID #{r.userid} {r.ridercontact ? `· ${r.ridercontact}` : ''}
</Text>
</VStack>
</HStack>
) : (
<Text type="supporting" weight="bold" color="secondary">
<Text type="supporting" weight="medium" color="secondary" style={{ fontStyle: 'italic' }}>
Unassigned
</Text>
);
}
},
{
id: 'eta',
accessorFn: (row) => row.expecteddeliverytime || undefined,
size: 100,
minSize: 90,
id: 'timingRoute',
accessorFn: (row) => row.expecteddeliverytime || row.kms,
size: 140,
minSize: 110,
sortUndefined: 'last',
meta: { label: 'ETA' },
meta: { label: 'ETA & Distance' },
header: () => (
<Text type="label" weight="bold" color="secondary">
ETA
ETA & Distance
</Text>
),
cell: ({ getValue }) => {
const v = getValue();
cell: ({ row }) => {
const r = row.original;
const eta = r.expecteddeliverytime ? parseDoormileTimestamp(r.expecteddeliverytime).format('hh:mm A') : null;
const kms = Number(r.kms);
return (
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{v ? parseDoormileTimestamp(v).format('hh:mm A') : '—'}
</Text>
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="bold" style={{ fontSize: 12 }}>
{eta ? `ETA ${eta}` : 'ETA —'}
</Text>
{kms ? (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{kms.toFixed(2)} km
</Text>
) : (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
—
</Text>
)}
</VStack>
);
}
},
{
id: 'kms',
accessorFn: (row) => Number(row.kms) || undefined,
size: 110,
minSize: 90,
id: 'amountQty',
accessorFn: (row) => Number(row.deliverycharges) || Number(row.deliveryamt),
size: 130,
minSize: 100,
sortUndefined: 'last',
meta: { label: 'Kms' },
meta: { label: 'Amount & Qty' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Kms
Amount & Qty
</Text>
),
cell: ({ getValue }) => (
<VStack gap={0.5} padding={0}>
<MetricPill variant="red" label={`${(getValue() ?? 0).toFixed(2)} km`} tooltip="Planned KMS" />
</VStack>
)
},
{
id: 'amount',
accessorFn: (row) => Number(row.deliverycharges) || undefined,
size: 150,
minSize: 120,
sortUndefined: 'last',
meta: { label: 'Amount' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Amount
</Text>
),
cell: ({ row }) => (
<VStack gap={0.5} padding={0}>
<MetricPill variant="red" label={`₹ ${row.original.deliverycharges?.toFixed(2) ?? '0.00'}`} tooltip="Delivery Charge" />
<MetricPill variant="green" label={`₹ ${row.original.deliveryamt?.toFixed(2) ?? '0.00'}`} tooltip="Delivery Amount" />
</VStack>
)
cell: ({ row }) => {
const r = row.original;
const amt = r.deliverycharges ?? r.deliveryamt;
return (
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="bold" style={{ fontSize: 13, color: DT.textPrimary }}>
{amt != null ? `₹ ${Number(amt).toFixed(2)}` : '—'}
</Text>
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{r.Quantity ? `${r.Quantity} items` : '1 parcel'}
</Text>
</VStack>
);
}
},
{
id: 'notes',
accessorKey: 'notes',
size: 170,
minSize: 120,
size: 120,
minSize: 90,
enableSorting: false,
meta: { label: 'Notes' },
header: () => (
@@ -1069,7 +1009,7 @@ const Deliveries = () => {
<Text
type="supporting"
color="secondary"
style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}
style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}
>
{v}
</Text>
@@ -1081,23 +1021,6 @@ const Deliveries = () => {
);
}
},
{
id: 'qty',
accessorKey: 'Quantity',
size: 80,
minSize: 64,
meta: { label: 'Qty' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Qty
</Text>
),
cell: ({ row }) => (
<Text weight="semibold" style={{ color: row.original.Quantity ? undefined : DT.textMuted, whiteSpace: 'nowrap' }}>
{row.original.Quantity || '—'}
</Text>
)
},
{
id: 'action',
size: 90,
@@ -1140,8 +1063,7 @@ const Deliveries = () => {
}
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[tablePagination.pageIndex, tablePagination.pageSize, productCollapse]
[tablePagination, buildRowMenuItems, productCollapse]
);
// Two sources feed one visibility slice: whatever the operator toggled in
@@ -1343,12 +1265,12 @@ const Deliveries = () => {
const errorMessage = fetchCountIsError
? `Error fetching count summary: ${fetchCountError?.message}`
: ridersListIsError
? `Error fetching riders: ${ridersListError?.message}`
: fetchtenantsIsError
? `Error tenant list: ${fetchtenantsError?.message}`
: fetchlocationsIsError
? `Error location list: ${fetchlocationsError?.message}`
: null;
? `Error fetching riders: ${ridersListError?.message}`
: fetchtenantsIsError
? `Error tenant list: ${fetchtenantsError?.message}`
: fetchlocationsIsError
? `Error location list: ${fetchlocationsError?.message}`
: null;
if (errorMessage) {
OpenToast(errorMessage, 'error', 2000);
@@ -1366,14 +1288,33 @@ const Deliveries = () => {
});
// Trims the redundant " Batch" off BATCH_OPTIONS' own label (which stays
// full elsewhere, e.g. the empty-state message) — "Afternoon Batch · 89"
// was overflowing/wrapping inside the selector's compact trigger, squeezed
// to a 200px FilterToolbar track alongside four other filters.
// full elsewhere, e.g. the empty-state message) — "Afternoon Batch" was
// overflowing/wrapping inside the selector's compact trigger, squeezed to a
// 200px FilterToolbar track alongside four other filters.
//
// The count is NOT in the label any more. It used to be appended as
// "Evening · 0", which put a number in the closed trigger where it read as
// part of the batch's name, and — on any batch with nothing in it — put a
// bare "· 0" in the filter row. A count belongs beside the option it counts,
// as a badge, and a zero is better said by absence than by a "0".
const batchSelectorOptions = BATCH_OPTIONS.map((b) => ({
value: b.id,
label: `${b.label.replace(/ Batch$/, '')} · ${batchTotals[b.id] ?? 0}`
label: b.label.replace(/ Batch$/, '')
}));
// The count rides in the open menu only. `renderOption` receives the option
// data, which carries `value` but not the total, so it is looked up here
// rather than smuggled through the label.
const renderBatchOption = (option) => {
const total = batchTotals[option.value] ?? 0;
return (
<HStack gap={2} vAlign="center" justify="between" style={{ width: '100%', minWidth: 0 }}>
<Text>{option.label}</Text>
{total > 0 && <Badge variant="neutral" label={total} />}
</HStack>
);
};
const renderProductDetails = () => (
<Card padding={0} elevation="none" style={{ margin: 16 }}>
<HStack gap={2} vAlign="center" padding={2} style={{ borderBottom: `1px solid ${DT.divider}`, background: tint(BRAND) }}>
@@ -1444,9 +1385,8 @@ const Deliveries = () => {
const emptyDescription =
selectedBatch === 'all'
? `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders for this filter.`
: `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${
BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'
}.`;
: `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'
}.`;
return (
<>
@@ -1454,7 +1394,7 @@ const Deliveries = () => {
<Loader />
)}
<PageShell fill>
<PageShell>
<PageHeader
title="Deliveries"
subtitle={`Live · ${locaName || 'All Zones'}`}
@@ -1470,48 +1410,66 @@ const Deliveries = () => {
}
/>
{/* All four tiles now read off the same countSourceRows-derived data
the StatusTabs pills use (batchTotals/batchCounts), instead of
three of them reading a separate, unscoped `fetchCountAPI` call —
fetchCountAPI ignores every argument passed to it (see api.js) and
returns an ALL-TIME, un-batched, un-dated total, which is why
"Pending Orders" could show a number the Pending tab itself
disagreed with. Same fix orders.js's KPI row already applies:
a tile and its tab must read the same source so they can't drift. */}
<KpiGrid>
{[
{ ...KPI_META[0], value: batchTotals.all },
{ ...KPI_META[1], value: batchCounts.uncoveredLength },
{ ...KPI_META[2], value: batchCounts.coveredLength },
{ ...KPI_META[3], value: batchCounts.cancelLength }
].map((item) => {
const Icon = item.icon;
return (
<StatCard
key={item.key}
title={item.label}
value={item.value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={countSourceIsLoading}
/>
);
})}
</KpiGrid>
{/* Status segment cards at the top of the page */}
<StatusTabs
value={currentStatus}
onChange={(status) => {
const idx = STATUS_TABS.findIndex((t) => t.status === status);
if (idx !== -1) handleChangetab(idx);
}}
tabs={[
...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })),
...(batchCounts.otherLength > 0
? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }]
: [])
]}
size="sm"
hasDivider={false}
hideHint={true}
/>
<DataCard
fill
toolbar={
<FilterToolbar max={5} minWidth={200}>
{/* Filters live OUTSIDE the table card.
Inside it, the batch/date/tenant/location/rider controls read as
part of the table's own chrome — a header band belonging to the
rows — when they actually decide WHICH rows exist. Lifting them
out makes the card start at the data, and the controls read as
what they are: page-level filters acting on the card below. */}
<div
className="dt-deliveries-toolbar"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '10px',
padding: '0',
width: '100%',
boxSizing: 'border-box'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '10px',
flex: '1 1 auto',
minWidth: 0
}}
>
<div style={{ flex: '1 1 130px', minWidth: 120, maxWidth: 180 }}>
<Selector
label="Batch"
isLabelHidden
options={batchSelectorOptions}
renderOption={renderBatchOption}
value={selectedBatch}
onChange={(v) => v && setSelectedBatch(v)}
placeholder="Batch"
/>
</div>
<div style={{ flexShrink: 0 }}>
<Button
label={
startdate && enddate ? `${dayjs(startdate).format('DD MMM')} – ${dayjs(enddate).format('DD MMM')}` : 'Select date range'
@@ -1520,7 +1478,9 @@ const Deliveries = () => {
icon={<MdOutlineDateRange size={16} />}
onClick={() => setOpen(true)}
/>
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector
label="Tenant"
isLabelHidden
@@ -1540,7 +1500,9 @@ const Deliveries = () => {
isDisabled={appId == null}
disabledMessage="Please select your zone first!"
/>
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector
label="Location"
isLabelHidden
@@ -1560,7 +1522,9 @@ const Deliveries = () => {
isDisabled={!tenantid}
disabledMessage={appId == null ? 'Please select your Zone and Tenant first!' : 'Please select your Tenant first!'}
/>
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector
label="Rider"
isLabelHidden
@@ -1573,61 +1537,51 @@ const Deliveries = () => {
isDisabled={riderListIsLoading || appId == null}
disabledMessage="Please select your zone first!"
/>
</FilterToolbar>
}
tabs={
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
<StatusTabs
value={currentStatus}
onChange={(status) => {
const idx = STATUS_TABS.findIndex((t) => t.status === status);
if (idx !== -1) handleChangetab(idx);
}}
tabs={[
...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })),
// Only ever rendered when a status arrived that this page has
// no tab for. Its presence is the signal that the mapping in
// api.js needs extending — the rows are reachable meanwhile.
...(batchCounts.otherLength > 0
? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }]
: [])
]}
size="sm"
hasDivider={false}
/>
<HStack gap={2} vAlign="center">
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</VStack>
</div>
</div>
{/* Columns menu — driven by the same `columnVisibility` slice
the tab uses for select/action, so there is one source of
truth for "is this column showing" (same pattern as
orders.js). Only rendered for the desktop table. */}
{!isMobile && (
<DropdownMenu
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
menuWidth={200}
placement="below"
>
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
label={column.columnDef.meta?.label ?? column.id}
value={column.getIsVisible()}
onChange={(checked) => column.toggleVisibility(checked)}
hasCloseOnSelect={false}
/>
))}
</DropdownMenu>
)}
</HStack>
</HStack>
}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
flex: '0 1 auto',
minWidth: 260,
justifyContent: 'flex-end'
}}
>
<div style={{ width: '100%', minWidth: 180, maxWidth: 260 }}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</div>
{/* Columns menu — driven by the same `columnVisibility` slice
the tab uses for select/action, so there is one source of
truth for "is this column showing" (same pattern as
orders.js). Only rendered for the desktop table. */}
{!isMobile && (
<DropdownMenu
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
menuWidth={200}
placement="below"
>
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
label={column.columnDef.meta?.label ?? column.id}
value={column.getIsVisible()}
onChange={(checked) => column.toggleVisibility(checked)}
hasCloseOnSelect={false}
/>
))}
</DropdownMenu>
)}
</div>
</div>
<DataCard>
{isMobile ? (
<MobileCardList scroll>
{filteredRows.length === 0 && !loading1 && !countSourceLoading && (
@@ -1815,9 +1769,11 @@ const Deliveries = () => {
Widths come from header.getSize(), the columnSizing slice
the drag handles write to. */}
<Table
density="compact"
dividers="none"
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Deliveries"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader>
@@ -1850,11 +1806,11 @@ const Deliveries = () => {
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
@@ -2082,8 +2038,7 @@ const Deliveries = () => {
onClick={() => {
setLoading1(true);
logger.info(
`Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${
selectedRider?.displayname || selectedRider?.authname
`Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${selectedRider?.displayname || selectedRider?.authname
}`
);
changeRiderMutation.mutate({ selectedRider, selectedRow });
@@ -2107,6 +2062,9 @@ const Deliveries = () => {
open={open}
toggle={() => setOpen(!open)}
id="daterange1"
// So the fields open showing the window already in force, rather
// than blank on an actively filtered table.
value={{ startDate: startdate || undefined, endDate: enddate || undefined }}
onChange={(range) => {
if (range.label === 'All') {
setStartdate('');