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) => (

{orDash(row.orderid)}

{orDash(row.tenantname)}

), }, { key: 'route', header: 'Route (pickup → drop)', width: '260px', className: 'max-w-[260px]', sortValue: (row) => row.pickupaddress || '', cell: (row) => (

Pickup {row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}

{orDash(row.deliverycustomer)} {row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}

), }, { 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 (

{name}

{orDash(contact)}

); }, hideBelow: 'md', }, { key: 'eta', header: 'ETA & distance', width: '120px', sortValue: (row) => row.expecteddeliverytime || '', cell: (row) => (

{formatDoormileTimestamp(row.expecteddeliverytime, 'hh:mm A')}

{formatKm(row.kms)}

), 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) => (
{row.statusfromconsignment ? ( from consignment ) : null}
), }, { key: 'actions', header: 'Actions', align: 'right', width: '170px', cell: (row) => (
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 ? ( 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) ? ( { setRiderRow(row); setSelectedRider(''); }} /> ) : null} {!isClient && rtoActionsFor(row).canStart ? ( setRtoRow(row)} /> ) : null} {!isClient && rtoActionsFor(row).canResolve ? ( <> setResolveRto({ row, mode: 'cancel' })} /> 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()) ? ( { setStatusRow(row); setNextStatus('delivered'); }} /> ) : null} {!['delivered', 'cancelled', 'canceled', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? ( { setCancelRow(row); setCancelReason(''); }} /> ) : null}
), }, ], [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 ( {/* Row 1: Tabs on left, DatePicker & Export on right */}
{ 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); }} />
{/* Row 2: Search, Zone, Batch, Clients, Milers, and Count */}
{/* 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 copied the time-range badge into the trigger too and needed 210px for it. The list is a compact label + time, no badges. */} {isFiltered && ( )}
{rows.length === batchRows.length ? `${batchRows.length} ${batchRows.length === 1 ? 'delivery' : 'deliveries'}` : `${rows.length} of ${batchRows.length} deliveries`}
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={ { setSearch(''); setBatch('all'); setTenantId(ALL); setRiderId(ALL); }, } : undefined } /> } />
!open && setRiderRow(null)} title="Change miler" description={riderRow ? `Order ${riderRow.orderid}` : undefined} icon={Bike} busy={changeRider.isPending} footer={ <> } > !open && setStatusRow(null)} title="Update status" description={statusRow ? `Order ${statusRow.orderid}` : undefined} icon={Truck} busy={updateStatus.isPending} footer={ <> } > {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. */ Status can only be updated once this order becomes a consignment — that happens when the rider completes pickup. ) : null} !open && setCancelRow(null)} title="Cancel this delivery" description={cancelRow ? `Order ${cancelRow.orderid}` : undefined} busy={cancelDelivery.isPending} footer={ <> } >