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)}
Pickup {row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
{orDash(row.deliverycustomer)} {row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
{name}
{orDash(contact)}
{formatDoormileTimestamp(row.expecteddeliverytime, 'hh:mm A')}
{formatKm(row.kms)}
Routing
{s.reason}
{s.route}
{s.baseAddress}
: null} {s.inwardedAt ? (Received at the base {formatDoormileTimestamp(s.inwardedAt)}
) : null}Loading order…
) : (Pickup
{orDash(booking?.routing?.from_address || row?.pickupaddress)}
{booking?.routing ? ({[ pickupSourceTypeLabel(booking.routing.pickup_source_type), booking.routing.pickup_source_name, booking.routing.from_pincode ] .filter(Boolean) .join(' · ')}
) : null}Drop
{orDash(booking?.routing?.to_address || row?.deliveryaddress)}
{booking?.routing?.destination_pincode ? ({booking.routing.destination_pincode}
) : null}Parcels {parcels.length ? `· ${parcels.length}` : ''}
{parcels.length === 0 ? (No parcel detail recorded on this order.
) : ({orDash(parcel.itemcategory)}
{orDash(parcel.itemdescription)} {parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
Notes
{row.notes}