import React, { useEffect, useMemo, useState } from 'react'; import dayjs from 'dayjs'; import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react'; import { Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, Input, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector, } from '@/components/ds'; import { ListToolbar } from '@/components/doormile/ListToolbar'; import { useBooking, useCancelDelivery, useChangeDeliveryRider, useDeliveries, useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants, useUpdateDeliveryStatus, } from '@/lib/doormileHooks'; import { useZone } from '@/lib/ZoneContext'; import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket'; import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp'; import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; import { pickupSourceTypeLabel } from '@/lib/orderFlow'; import { summariseRouting } from '@/lib/routingSummary'; /** * Deliveries — orders that have moved past merely being created. * * The rows are built from `/admin/bookings` filtered to those with a rider or a * consignment, joined against customers, riders, clients and consignments; see * `fetchDeliveries` for why the consignments endpoint cannot be the source on * its own, and for which columns are deliberately left blank rather than filled * with numbers the API never returned. * * A row's status comes from its consignment when it has one. A booking freezes * at `Converted_To_Consignment` the moment it is picked up, so a row can * legitimately read "Active" while the booking still says otherwise — which is * why the table can say where the value came from. */ /* `other` is not declared here — it is appended only when rows actually land in it, so the strip never carries a permanently empty tab. */ const STATUS_TABS = [ { value: 'all', label: 'All', countKey: 'total' }, { value: 'pending', label: 'Pending', countKey: 'uncoveredLength' }, { value: 'accepted', label: 'Accepted', countKey: 'assignedLength' }, { value: 'arrived', label: 'Arrived', countKey: 'arrivedLength' }, { value: 'picked', label: 'Picked', countKey: 'pickedLength' }, { value: 'active', label: 'Active', countKey: 'activeLength' }, { value: 'skipped', label: 'Skipped', countKey: 'skippedLength' }, { value: 'delivered', label: 'Delivered', countKey: 'coveredLength' }, { value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' }, ]; const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled'); const BATCH_OPTIONS = [ { id: 'all', label: 'All batches', range: 'Across the day' }, ...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })), ]; const ALL = 'all'; /** The statuses this console can actually write to a consignment. */ const SETTABLE_STATUSES = [ { value: 'active', label: 'Out for delivery' }, { value: 'delivered', label: 'Delivered' }, { value: 'cancelled', label: 'Cancelled' }, ]; export default function Deliveries() { const todayStr = dayjs().format('YYYY-MM-DD'); const [selectedDate, setSelectedDate] = useState(todayStr); const [tab, setTab] = useState('all'); const [batch, setBatch] = useState('all'); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search); const [page, setPage] = useState(1); const [tenantId, setTenantId] = useState(ALL); const [riderId, setRiderId] = useState(ALL); const [detailRow, setDetailRow] = useState(null); const [riderRow, setRiderRow] = useState(null); const [selectedRider, setSelectedRider] = useState(''); const [statusRow, setStatusRow] = useState(null); const [nextStatus, setNextStatus] = useState('delivered'); const [cancelRow, setCancelRow] = useState(null); const [cancelReason, setCancelReason] = useState(''); const dateParams = useMemo(() => { if (!selectedDate || selectedDate === 'all') return { from: '', to: '' }; if (typeof selectedDate === 'string') return { from: selectedDate, to: selectedDate }; return { from: selectedDate.from || '', to: selectedDate.to || '' }; }, [selectedDate]); const { data, isLoading, isFetching } = useDeliveries({ page, pageSize: 100, from: dateParams.from, to: dateParams.to, }); const { data: counts } = useDeliveryCounts(); const { data: riders = [] } = useRiderOptions(); const { data: tenants = [] } = useTenants(); const changeRider = useChangeDeliveryRider(); const updateStatus = useUpdateDeliveryStatus(); const cancelDelivery = useCancelDelivery(); const notifyMiler = useNotifyMiler(); const { selectedZone, isAllZones, matchesZone } = useZone(); const riderLookup = useMemo(() => { const map = new Map(); (riders || []).forEach((r) => { const name = r.displayname || [r.firstname, r.lastname].filter(Boolean).join(' ').trim() || r.name || r.authname || (r.label ? r.label.split('|')[0].trim() : ''); const phone = r.contactno || r.phone || ''; if (r.userid != null) map.set(String(r.userid), { name, phone }); if (r.milerid != null) map.set(String(r.milerid), { name, phone }); if (r.milerprofileid != null) map.set(String(r.milerprofileid), { name, phone }); if (r.id != null) map.set(String(r.id), { name, phone }); if (r.value != null) map.set(String(r.value), { name, phone }); }); return map; }, [riders]); const allRows = useMemo(() => { return [...(data?.rows || [])].sort((a, b) => { const ta = parseDoormileTimestamp(a.orderdate || a.assigntime).valueOf() || 0; const tb = parseDoormileTimestamp(b.orderdate || b.assigntime).valueOf() || 0; return tb - ta; }); }, [data?.rows]); /* Zone, Batch, client and rider first, then status, then search — the counts under the tabs have to describe the slice the operator has chosen, not the whole day, or a tab reads 6 and shows 2. */ const batchRows = useMemo( () => allRows .filter(matchesZone) .filter((row) => batch === 'all' || getRowBatchId(row) === batch) .filter((row) => tenantId === ALL || String(row.tenantid) === tenantId) .filter((row) => riderId === ALL || String(row.userid) === riderId), [allRows, matchesZone, batch, tenantId, riderId] ); const unmapped = useMemo( () => batchRows.filter((row) => !KNOWN_STATUSES.includes(String(row.orderstatus || '').toLowerCase())), [batchRows] ); const tabs = useMemo(() => { const base = STATUS_TABS.map((entry) => ({ value: entry.value, label: entry.label, count: entry.value === 'all' ? batchRows.length : batchRows.filter((row) => String(row.orderstatus || '').toLowerCase() === entry.value).length, })); /* A row whose status matches no tab used to be counted nowhere and shown nowhere. It gets its own tab, but only when one actually exists. */ if (unmapped.length) base.push({ value: 'other', label: 'Other', count: unmapped.length }); return base; }, [batchRows, unmapped]); const rows = useMemo(() => { let scoped = batchRows; if (tab === 'other') scoped = unmapped; else if (tab !== 'all') { scoped = batchRows.filter((row) => { const status = String(row.orderstatus || '').toLowerCase(); return tab === 'cancelled' ? status === 'cancelled' || status === 'canceled' : status === tab; }); } return scoped.filter((row) => matchesQuery( row, [ 'orderid', 'bookingno', 'bookingid', 'consignmentid', 'deliverycustomer', 'ridername', 'pickupaddress', 'deliveryaddress', 'tenantname', ], debouncedSearch ) ); }, [batchRows, tab, unmapped, debouncedSearch]); /* Whether anything is narrowing the list — drives the empty state's wording and whether Clear filters is offered. It has to name every filter, or the button appears and then visibly fails to clear one of them. */ const isFiltered = Boolean(debouncedSearch) || batch !== 'all' || tenantId !== ALL || riderId !== ALL; /* A new filter re-scopes the list, so page 1 is the only page that still means anything. */ useEffect(() => { setPage(1); }, [tab, batch, tenantId, riderId, debouncedSearch, dateParams.from, dateParams.to]); const columns = useMemo( () => [ { key: 'orderid', header: 'Order & client', sortable: true, width: '130px', cell: (row) => (

{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: '130px', cell: (row) => (
setDetailRow(row)} /> {row.orderstatus !== 'delivered' && 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} { setStatusRow(row); setNextStatus('delivered'); }} /> {!['delivered', 'cancelled', 'canceled'].includes(String(row.orderstatus || '').toLowerCase()) ? ( { setCancelRow(row); setCancelReason(''); }} /> ) : null}
), }, ], [notifyMiler] ); const exportColumns = [ { key: 'orderid', header: 'Order' }, { key: 'tenantname', header: 'Client' }, { key: 'deliverycustomer', header: 'Customer' }, { key: 'ridername', header: 'Miler' }, { key: 'orderstatus', header: 'Status' }, { key: 'kms', header: 'Km' }, { key: 'deliverycharges', header: 'Amount' }, { key: 'orderdate', header: 'Created' }, ]; return ( { if (d) { const s = dayjs(d).format('YYYY-MM-DD'); setDateParams({ from: s, to: s }); setSelectedDate(s); } }} /> } /> exportRows(rows, exportColumns, `deliveries-${tab}-${dateParams.from || 'all'}`)} disabled={rows.length === 0} > Export } > 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={ <> } >