931 lines
36 KiB
JavaScript
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>
|
|
);
|
|
}
|