Files
doormilxpress_astryx/src/pages/doormile/deliveries/Deliveries.jsx

931 lines
36 KiB
JavaScript

import React, { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { Ban, Bell, Bike, Clock, FileSpreadsheet, PackageCheck, PackageSearch, RotateCcw, Truck, Undo2 } from 'lucide-react';
import {
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds';
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 { toDateRange } from '@/lib/dateRange';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
import { summariseRouting } from '@/lib/routingSummary';
import { useAuth } from '@/lib/AuthContext';
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
/**
* 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' },
// Reverse logistics: parcels going back to the sender, and those back.
{ value: 'rto', label: 'In return', countKey: 'rtoLength' },
{ value: 'returned', label: 'Returned', countKey: 'returnedLength' },
];
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
// `short` is what the list shows: the trigger's clock icon already says
// "batch", so "Morning Batch" repeated the word on every row.
const BATCH_OPTIONS = [
{ id: 'all', label: 'All batches', short: 'All batches', range: 'All day' },
...BATCHES.map((batch) => ({
id: batch.id,
label: batch.label,
short: batch.label.replace(/\s*batch$/i, ''),
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('');
// Reverse logistics. Starting/closing a return is a Doormile-staff action;
// a client login sees the statuses but no buttons (the server refuses too).
const { isClient } = useAuth();
const [rtoRow, setRtoRow] = useState(null);
const [resolveRto, setResolveRto] = useState({ row: null, mode: 'cancel' });
const dateParams = useMemo(() => toDateRange(selectedDate), [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: '170px',
cell: (row) => (
<div className="flex justify-end gap-1">
<IconButton
label="Order details"
icon={PackageSearch}
variant="ghost"
size="sm"
onClick={() => setDetailRow(row)}
/>
{/* Not on parcels in or through a return: the generic "process
deliveries" text is wrong for them, and starting a return
already pushes the rider a return-specific message. */}
{!['delivered', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) && 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}
{!isClient && rtoActionsFor(row).canStart ? (
<IconButton
label={row.orderstatus === 'delivered' ? `Customer return (${rtoActionsFor(row).daysRemaining}d left)` : "Return to sender"}
icon={Undo2}
variant="ghost"
size="sm"
onClick={() => setRtoRow(row)}
/>
) : null}
{!isClient && rtoActionsFor(row).canResolve ? (
<>
<IconButton
label="Re-attempt delivery"
icon={RotateCcw}
variant="ghost"
size="sm"
onClick={() => setResolveRto({ row, mode: 'cancel' })}
/>
<IconButton
label="Mark returned"
icon={PackageCheck}
variant="ghost"
size="sm"
onClick={() => setResolveRto({ row, mode: 'complete' })}
/>
</>
) : null}
{/* A parcel in or through a return is moved only by the return
actions above — the server refuses a plain status change. */}
{!['rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? (
<IconButton
label="Update status"
icon={Truck}
variant="ghost"
size="sm"
onClick={() => {
setStatusRow(row);
setNextStatus('delivered');
}}
/>
) : null}
{!['delivered', 'cancelled', 'canceled', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? (
<IconButton
label="Cancel delivery"
icon={Ban}
variant="ghost"
size="sm"
onClick={() => {
setCancelRow(row);
setCancelReason('');
}}
/>
) : null}
</div>
),
},
],
[notifyMiler, isClient]
);
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">
<Stack space="sm">
{/* Row 1: Tabs on left, DatePicker & Export on right */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs tabs={tabs} value={tab} onChange={setTab} className="shrink-0" />
</div>
<div className="flex items-center gap-2 shrink-0">
<DatePicker
value={selectedDate}
onChange={(d) => {
if (!d) return;
// Store exactly what the picker emits. It speaks three shapes
// — 'YYYY-MM-DD', { from, to } for the range presets, and
// 'all' — and `dateParams` below already reads all three.
// Forcing every one through dayjs().format() turned both the
// range presets and All Dates into the string "Invalid Date".
//
// The setter that used to be called here first, setDateParams,
// does not exist: `dateParams` is derived, not state. The
// ReferenceError it threw ran before setSelectedDate, which is
// why picking a date did nothing whatsoever.
setSelectedDate(d);
}}
/>
<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>
</div>
</div>
{/* Row 2: Search, Zone, Batch, Clients, Milers, and Count */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="flex flex-1 flex-wrap items-center gap-2.5 min-w-0">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by order, client, miler, customer..."
className="w-full sm:w-64 lg:w-72"
/>
<ZoneSelector className="w-full sm:w-auto shrink-0" />
{/* Batch. Sized like the Clients and Milers selects beside it.
The trigger names the batch only: SelectItem puts an option's
whole content in ItemText, so a bare <SelectValue/> copied the
time-range badge into the trigger too and needed 210px for it.
The list is a compact label + time, no badges. */}
<Select value={batch} onValueChange={setBatch}>
<SelectTrigger className="w-full sm:w-44" aria-label="Batch">
<div className="flex items-center gap-2 min-w-0">
<Clock className="h-3.5 w-3.5 text-ink-3 shrink-0" />
<SelectValue>
<span className="truncate">{BATCH_OPTIONS.find((o) => o.id === batch)?.label || 'All batches'}</span>
</SelectValue>
</div>
</SelectTrigger>
<SelectContent className="min-w-[var(--radix-select-trigger-width)] w-52">
{BATCH_OPTIONS.map((option) => (
<SelectItem
key={option.id}
value={option.id}
textValue={option.label}
// ItemText is SelectItem's last child and sizes to its text;
// stretched, the times line up in one right-hand column.
className="cursor-pointer [&>span:last-child]:w-full"
>
<span className="flex w-full items-center justify-between gap-3">
<span className="text-ink-1">{option.short}</span>
<span className="text-xs text-ink-4 tabular-nums">{option.range}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tenantId} onValueChange={setTenantId}>
<SelectTrigger className="w-full sm:w-44">
<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-44">
<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>
{isFiltered && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearch('');
setBatch('all');
setTenantId(ALL);
setRiderId(ALL);
}}
className="text-xs h-9 text-brand hover:text-brand-hover hover:bg-brand-soft"
>
Clear filters
</Button>
)}
</div>
<div className="text-caption text-ink-3 shrink-0">
{rows.length === batchRows.length
? `${batchRows.length} ${batchRows.length === 1 ? 'delivery' : 'deliveries'}`
: `${rows.length} of ${batchRows.length} deliveries`}
</div>
</div>
<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>
<StartRtoModal row={rtoRow} onClose={() => setRtoRow(null)} />
<ResolveRtoModal
row={resolveRto.row}
mode={resolveRto.mode}
onClose={() => setResolveRto({ row: null, mode: 'cancel' })}
/>
<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>
{/* Attempts, base hand-overs and any return, in order. */}
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Timeline</p>
<ConsignmentTimeline consignmentId={row?.consignmentid} />
</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>
);
}