import logger from '../../../utils/logger'; import { enqueueSnackbar } from 'notistack'; import { useState, useEffect, Fragment, useRef, useMemo } from 'react'; import dayjs from 'dayjs'; var utc = require('dayjs/plugin/utc'); dayjs.extend(utc); import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi'; import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { BATCHES as SHARED_BATCHES, batchRangeLabel } from 'utils/batchBucket'; import { MdOutlineDateRange, MdDirectionsBike, MdNotificationsActive, MdCheckCircle, MdCancel, MdInventory2, MdKeyboardArrowDown, MdKeyboardArrowUp, MdArrowDownward, MdArrowUpward, MdUnfoldMore, MdViewColumn } from 'react-icons/md'; import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; // Same TanStack Table v9 entry point orders.js uses — table-core's feature // modules come through react-table's re-export, see that file's comment. import { useTable, flexRender, tableFeatures, rowSelectionFeature, columnVisibilityFeature, rowSortingFeature, createSortedRowModel, sortFns, columnSizingFeature, columnResizingFeature, rowPaginationFeature, createPaginatedRowModel } from '@tanstack/react-table'; import { addDays, addMonths, addWeeks, endOfMonth, endOfWeek, startOfMonth, startOfWeek } from 'date-fns'; import { DateRangePicker } from 'components/nearle_components/DateRangePicker'; import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Center } from '@astryxdesign/core/Center'; import { Card } from '@astryxdesign/core/Card'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Badge } from '@astryxdesign/core/Badge'; import { Divider } from '@astryxdesign/core/Divider'; import { TextInput } from '@astryxdesign/core/TextInput'; import { NumberInput } from '@astryxdesign/core/NumberInput'; import { TextArea } from '@astryxdesign/core/TextArea'; import { Selector } from '@astryxdesign/core/Selector'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { Pagination } from '@astryxdesign/core/Pagination'; import { DropdownMenu, DropdownMenuCheckboxItem } from '@astryxdesign/core/DropdownMenu'; import { MoreMenu } from '@astryxdesign/core/MoreMenu'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import Loader from 'components/Loader'; import { cancelDeliveryAPI, changeRiderAPI, fetchCountAPI, fetchDeliveries, fetchRidersList, notifyRider, updateDeliveryAPI, getorderdetails, gettenantlocations, getTenants } from 'pages/api/api'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import { OpenToast } from 'components/third-party/OpenToast'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import PageHeader from 'components/nearle_components/PageHeader'; import StatusBadge from 'components/nearle_components/StatusBadge'; import StatusTabs from 'components/nearle_components/StatusTabs'; import TableLoader from 'components/nearle_components/TableLoader'; import { PageShell, DataCard } from 'components/nearle_components/PageLayout'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport'; // ============================================================================ // Design tokens — shared across every DT-styled operator page so filters, // KPIs, tabs, status badges, and dialogs stay visually consistent. // See src/themes/dt/tokens.js for the canonical source. // ============================================================================ import { DT, tint } from 'themes/dt/tokens'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives'; import { getStatusMeta } from 'themes/dt/status'; const BRAND = DT.brand; // Ordered status list driving the tabs row (left → right). Each entry binds // the canonical status registry (themes/dt/status.js) key to the // `currentStatus` key the queries use AND to the `batchCounts` key (so the // chip count for the tab is one lookup). // 'other' is NOT in this list — it is appended at render time and only when // rows actually land in it, so the strip doesn't carry a permanently-empty tab. const KNOWN_TAB_STATUSES = ['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped', 'delivered', 'cancelled', 'canceled']; const STATUS_TABS = [ { status: 'pending', countKey: 'uncoveredLength' }, { status: 'accepted', countKey: 'assignedLength' }, { status: 'arrived', countKey: 'arrivedLength' }, { status: 'picked', countKey: 'pickedLength' }, { status: 'active', countKey: 'activeLength' }, { status: 'skipped', countKey: 'skippedLength' }, { status: 'delivered', countKey: 'coveredLength' }, { status: 'cancelled', countKey: 'cancelLength' } ]; // Batches mirror the dispatch page's slot definitions so an operator who // segments the day there sees the same buckets here. Hours are 24h, half-open // [startHour, endHour) — a delivery at exactly endHour falls into the *next* // batch (or none, if the gap isn't covered). // Windows and ranges come from utils/batchBucket.js — the same source Dispatch // derives its own from. They were three hand-maintained copies of numbers that // must agree; a row bucketed one way here and another on Dispatch is the same // bug twice. Only the presentation (colour, icon) is local. const BATCH_PRESENTATION = { morning: { color: '#0ea5e9', iconKey: 'morning' }, afternoon: { color: '#f59e0b', iconKey: 'afternoon' }, evening: { color: '#6366f1', iconKey: 'evening' } }; const BATCH_OPTIONS = [ { id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' }, ...SHARED_BATCHES.map((b) => ({ id: b.id, label: b.label, range: batchRangeLabel(b), startHour: b.startHour, endHour: b.endHour, ...BATCH_PRESENTATION[b.id] })) ]; // Auto-pick the batch matching the operator's LOCAL wall-clock hour so the // page lands them on the slot they're most likely curious about. Falls back // to 'all' when the current hour lies in a configured gap (8–9 AM, 12 PM–4 // PM, after 7 PM). Local time, not UTC, to match the dispatch page's // bucketing — both pages must agree on which batch a given row belongs to. const detectInitialBatchId = () => { const now = dayjs(); const h = now.hour() + now.minute() / 60; for (const b of BATCH_OPTIONS) { if (b.id === 'all') continue; if (h >= b.startHour && h < b.endHour) return b.id; } return 'all'; }; // Bucket by `orderdate` only — the booking's `createdat`, i.e. when the order // came in. Must stay identical to the dispatch page's bucket field // (Dispatch.js's `BATCH_TIME_FIELD = 'created'`, whose `keys` is // `['orderdate']`), or the same row lands in different batches on the two // pages — see the dispatch folder's CLAUDE.md §1. // // Three earlier attempts were wrong: // • `deliverytime` mis-bucketed delivered orders into the wave they were // *completed* in rather than the wave they belonged to, so an order due at // 07:00 but delivered at 11:00 fell into the 8–9 AM gap and vanished from // every batch here while still showing on dispatch. // • `assigntime` looked correct (the spec says "bucket by assign time") but // the Doormile backend has no assignment timestamp — api.js maps that field // to the booking's last-modified column, so it is re-stamped by any status // change and orders drifted into whichever batch held the current clock // time. // • `expecteddeliverytime` is stable, but it is the PROMISED DELIVERY slot, // not the wave the order belongs to. An order created at 12:22 whose SLA // put its ETA at 16:30 counted towards Evening, so the Evening batch showed // orders nobody had placed yet. // // Creation time has neither failure mode: it is immutable, and no batch later // than the current clock time can ever show a count for today. // // Rows with no creation timestamp return null and intentionally don't bucket — // matches dispatch. const BATCH_TIME_KEYS = ['orderdate']; // Returns one of the batch ids in BATCH_OPTIONS (excluding 'all'), or null // when the row has no usable timestamp / falls in a gap between batches. const getRowBatchId = (row) => { let t = null; for (const k of BATCH_TIME_KEYS) { if (row?.[k]) { t = row[k]; break; } } if (!t) return null; const str = String(t).trim(); // Bare YYYY-MM-DD with no time component would always parse to midnight and // get mis-bucketed into Morning — skip those. if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; // Parse in LOCAL time to match the dispatch page's bucketing exactly // (`Dispatch.js:218`). Dispatch never uses `.utc()` for batch assignment, // so a row that lands in Evening Batch on the dispatch page must also land // in Evening Batch here — otherwise an operator running in IST/PST/etc. // sees different totals on the two pages for the same underlying rows. // parseDoormileTimestamp (not bare dayjs()) strips a false trailing Z some // Doormile timestamps carry (see utils/doormileTimestamp.js) — without // this, a booking assigned "just now" could get shifted +5:30 (IST) past // every batch window into a gap, silently vanishing from the table even // though it was correctly assigned server-side. const d = parseDoormileTimestamp(t); if (!d.isValid()) return null; const h = d.hour() + d.minute() / 60; for (const b of BATCH_OPTIONS) { if (b.id === 'all') continue; if (h >= b.startHour && h < b.endHour) return b.id; } return null; }; // Astryx's Badge IS this pill. The hand-rolled
hardcoded 11px/700, its // own radius, border and a 75px min-width against the theme, so these cells // couldn't move with the type scale. `variant` is an enumerated Badge colour // rather than a hex: these are metric readouts (km, ₹), not lifecycle // statuses, so they must not go through StatusBadge. const MetricPill = ({ variant = 'neutral', label, tooltip }) => tooltip ? ( ) : ( ); // TanStack Table v9 registers features explicitly, same shape as orders.js's // ORDERS_TABLE_FEATURES. Declared at module scope so the reference is stable // across renders. Column FILTERING is deliberately absent — the tab strip, // batch selector and search filter upstream in `filteredRows`. const DELIVERIES_TABLE_FEATURES = tableFeatures({ rowSelectionFeature, columnVisibilityFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), sortFns, columnSizingFeature, columnResizingFeature, rowPaginationFeature, paginatedRowModel: createPaginatedRowModel() }); // Page sizes offered under the desktop table. 50 keeps the previous default. const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; // ================================================= || deliveries (initial point)|| ================================================= const Deliveries = () => { const userid = localStorage.getItem('userid'); // Below the narrow-viewport breakpoint we swap the wide data table for an // app-style card list. Desktop keeps the exact same table. const isMobile = useIsNarrowViewport(); const loadMoreRef = useRef(); const [deliverylist, setDeliverylist] = useState([]); const [dialogopen, setDialogopen] = useState(false); const [locaName, setLocoName] = useState('All'); const [appId, setAppId] = useState(0); const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD')); const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD')); const [tabstatus, setTabstatus] = useState('Pending'); const [open, setOpen] = useState(false); const [kms, setKms] = useState(''); const [cumulativekms, setCumulativeKms] = useState(); const [deliveryamount, setDeliveryamount] = useState(); const [notes, setNotes] = useState(''); const [currentorder, setCurrentorder] = useState({}); // Real Actual KMs for the Update Status dialog, computed from real GPS // ping history instead of leaving the field permanently blank (there is // no backend-stored actual-distance field — see fetchDeliveries in // api.js). Same sort -> Kalman-smooth -> polyline-length pipeline // Dispatch.js's Compare feature already uses to show a real "actual km" // figure, just reused here (see dispatchShared.js). Keyed off // consignmentid, NOT deliveryid/bookingid — GET /admin/consignments/:id/logs // is consignment-scoped (the same id-class mix-up already fixed in // updateDeliveryAPI this session). const { data: computedActualKm } = useQuery({ queryKey: ['deliveryActualKm', currentorder?.consignmentid], queryFn: async () => { let rows = []; try { rows = (await getConsignmentLogs(currentorder.consignmentid)) || []; } catch { return null; } const sorted = rows .map((r) => { const ts = r?.logdate ? dayjs(r.logdate) : null; return { lat: parseFloat(r?.latitude ?? r?.lat), lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon), logdate: r?.logdate, _ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER }; }) .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng)) .sort((a, b) => a._ts - b._ts); // Fewer than 2 usable pings (order not yet delivered, or no telemetry // recorded) — a real, expected case, not an error. Leave the field // to its existing blank/manual-entry behavior. if (sorted.length < 2) return null; const smoothed = kalmanSmoothGps(sorted); const km = polylineLengthKm(smoothed.map((p) => [p.lat, p.lng])); return km > 0 ? km : null; }, enabled: dialogopen && !!currentorder?.consignmentid, staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false }); // Only pre-fill while the field is still empty — never overwrite an // operator's own edit (or an earlier pre-fill) if this query happens to // re-run while the dialog is already open. useEffect(() => { if (computedActualKm != null && !cumulativekms) { setCumulativeKms(Number(computedActualKm.toFixed(2))); } }, [computedActualKm]); const [deliverylat, setDeliverylat] = useState(''); const [deliverylong, setDeliverylong] = useState(''); const [currentStatus, setCurrentStatus] = useState('pending'); const [updateStatus, setUpdateStatus] = useState('delivered'); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(50); const [productCollapse, setProductCollapse] = useState(null); const [orderHeaderid, setOrderHeaderId] = useState(null); const [searchword, setSearchword] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [selectedRow, setSelectedRow] = useState(null); const [loading1, setLoading1] = useState(false); const [cancelDeliveryOpen, setCancelDeliveryOpen] = useState(false); const [changeDialogOpen, setChangeDialogOpen] = useState(false); const [cancelFeed, setCancelFeed] = useState(''); const [selectedRider, setSelectedRider] = useState(null); const [tenantid, setTenantid] = useState(0); const [locationid, setLocationid] = useState(0); const [riderid, setRiderid] = useState(0); // Selected batch id — drives the client-side row filter. Defaults to the // batch matching the current UTC hour (the operator is most likely curious // about "now"); never `null` since there's no longer an "All" option. const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId); // showAction/showSelect only depend on tabstatus, computed here (rather than // just before the return, where they used to live) so the TanStack Table // hooks below — which need them for column visibility — can run // unconditionally, before the `errorMessage` early return further down. const showAction = tabstatus !== 'Cancelled' && tabstatus !== 'Delivered'; // No tab ever sets tabstatus to 'Created' (see handleChangetab below), so // this — and the desktop table's whole select column — is currently // unreachable. Left as-is; not this task's scope to fix. const showSelect = tabstatus == 'Created'; // Desktop table's own pagination/sorting/selection/column-visibility state — // deliberately separate from `page`/`rowsPerPage` above, which still drive // fetchCountAPI's query params and the mobile card list's row numbering. // Mixing the two would tie an unrelated API call's params to a UI-only // pagination control. const [sorting, setSorting] = useState([]); const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); const [rowSelection, setRowSelection] = useState({}); const [userColumnVisibility, setUserColumnVisibility] = useState({}); useEffect(() => { setTenantid(0); setLocationid(0); }, [appId]); // to clear the location autocomplete useEffect(() => { setLocationid(0); }, [tenantid]); // =========================================== || cancelDelivery || =========================================== const { mutate: cancelDelivery } = useMutation({ mutationFn: ({ selectedRow, cancelFeed }) => cancelDeliveryAPI(selectedRow, cancelFeed), onSuccess: () => { opentoast('Delivery Cancelled Successfully', 'success'); setCancelDeliveryOpen(false); fetchCountRefetch(); // Refresh count data countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips }, onError: (error) => { opentoast(error.message, 'error'); } }); // ==============================|| cancelridernotification ||============================== // const cancelridernotification = async () => { try { // /admin/milers/:id/notify keys off milerprofileid, not the userid // stored on the booking (confirmed live) — see fetchDeliveries in api.js. return await notifyMiler(selectedRow.milerprofileid, 'DoormileXpress', `${selectedRow.orderid} have been Cancelled`); } catch (err) { opentoast(err.message, 'error', 2000); } }; // ==============================|| getTenants ||============================== // const { data: tenantlist, isLoading: fetchtenantsIsLoading, isError: fetchtenantsIsError, error: fetchtenantsError } = useQuery({ queryKey: ['tenantlist', appId], queryFn: () => getTenants(appId), // Ensure appId is passed // appId 0 is a real, deliberate choice — the synthetic "All" zone option // (fetchAppLocations in api.js) — not "nothing picked yet"; only // null/undefined means that now. See LocationAutocomplete.js's onChange. enabled: appId != null }); // ==============================|| gettenantlocations ||============================== // const { data: locationlist, isLoading: fetchlocationsIsLoading, isError: fetchlocationsIsError, error: fetchlocationsError } = useQuery({ queryKey: ['gettenantlocations', tenantid], queryFn: () => gettenantlocations(tenantid), // Ensure appId is passed enabled: tenantid !== 0 // Ensures query runs only when appId is valid }); // =========================================== || notifyrider || =========================================== const notifyRiderMutation = useMutation({ mutationFn: notifyRider, // Using the separate function onSuccess: () => { enqueueSnackbar('Notification sent Successfully', { variant: 'success', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 }); }, onError: (error) => { enqueueSnackbar(error.message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 }); } }); // =========================================== || changerider || =========================================== const changeRiderMutation = useMutation({ mutationFn: ({ selectedRider, selectedRow }) => changeRiderAPI(selectedRider, selectedRow), onSuccess: (res, { selectedRider, selectedRow }) => { setLoading1(false); setChangeDialogOpen(false); if (res.success) { logger.info( `Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname }` ); opentoast('Rider Changed Successfully', 'success'); } fetchCountRefetch(); // Refresh count data countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips // notifyMiler wants a miler profile id, not an FCM token — the new API // looks the device token up server-side. selectedRider comes straight // from getMilers(), which has milerprofileid directly (confirmed live; // .userid there is the underlying app-user id, a different resource). notifyRiderMutation.mutate(selectedRider.milerprofileid); }, onError: (err, { selectedRow }) => { logger.error(`Failed to change rider for order ID ${selectedRow?.orderid}:`, err); opentoast(err.message, 'error'); setLoading1(false); } }); /* ============================================= || opentoast || ============================================= */ const opentoast = (message, variant) => { enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 }); }; // ==============================|| getorderdetails ||============================== // const { data: orderdetails } = useQuery({ queryKey: ['orderdetails', orderHeaderid], queryFn: () => getorderdetails(orderHeaderid), enabled: !!orderHeaderid // ✅ prevent initial fetch when undefined }); const dialogclose = () => { setDialogopen(false); }; const handleChangetab = (i) => { setPage(0); setRowsPerPage(50); if (i === 0) { setTabstatus('Pending'); setCurrentStatus('pending'); } if (i === 1) { setTabstatus('Assigned'); setCurrentStatus('accepted'); } if (i === 2) { setTabstatus('Arrived'); setCurrentStatus('arrived'); } if (i === 3) { setTabstatus('Picked'); setCurrentStatus('picked'); } if (i === 4) { setTabstatus('Active'); setCurrentStatus('active'); } if (i === 5) { setTabstatus('Skipped'); setCurrentStatus('skipped'); } if (i === 6) { setTabstatus('Delivered'); setCurrentStatus('delivered'); } if (i === 7) { setTabstatus('Cancelled'); setCurrentStatus('cancelled'); } setSearchword(''); setTablePagination((p) => ({ ...p, pageIndex: 0 })); setRowSelection({}); }; const okclicked = () => { setOpen(false); }; // Parallel "all-statuses" query used ONLY to derive batch-aware tab counts. // The primary `fetchdeliveries` query above is keyed by `currentStatus`, so // its rows are scoped to the active tab — they can't tell us how many // delivered/cancelled/etc. exist in the same batch. We fetch the full day // once (status='all'), keep it cached, and re-derive the counts per status // whenever the operator switches batches. Search keyword is intentionally // dropped from this key so the chip counts reflect the batch totals rather // than the search-filtered subset. const { data: countSourceData, fetchNextPage: countFetchNext, hasNextPage: countHasNext, isFetchingNextPage: countIsFetchingNext, isLoading: countSourceIsLoading, refetch: countSourceRefetch } = useInfiniteQuery({ queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid], queryFn: fetchDeliveries, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined }); // Newest activity first — an order just created and assigned should be the // very first row an operator sees, not buried wherever GET /admin/bookings // happens to return it (unconfirmed/unspecified server-side order). Every // row here has been dispatched at least once (fetchDeliveries filters to // that), so assigntime (booking.updatedat) is the more recent of the two // timestamps on a fresh assignment; orderdate (createdat) covers the rare // row missing it. parseDoormileTimestamp (not bare dayjs()) avoids the // false-Z parsing bug documented in utils/doormileTimestamp.js. const countSourceRows = useMemo(() => { const rows = (countSourceData?.pages || []).flatMap((p) => p.rows || []); return [...rows].sort( (a, b) => parseDoormileTimestamp(b.assigntime || b.orderdate).valueOf() - parseDoormileTimestamp(a.assigntime || a.orderdate).valueOf() ); }, [countSourceData]); useEffect(() => { if (countHasNext && !countIsFetchingNext) countFetchNext(); }, [countHasNext, countIsFetchingNext, countFetchNext, countSourceRows.length]); // Loading flag for the table empty state — true while we're still streaming // pages, so we don't show "No Orders" prematurely before the full // day's rows have arrived. Goes false once auto-pagination drains. const countSourceLoading = countHasNext || countIsFetchingNext; // The table now reads from the same source as the chip counts. That way the // "74" in the Delivered chip and the rows shown in the table can never // disagree (they used to, because the table consumed a separate per-status // paginated query while the chips read from the full all-statuses dataset). // Three filter steps: // 1. Batch — selected via the dropdown. 'all' bypasses this filter. // 2. Tab status — currentStatus comes from handleChangetab. // 3. Search — client-side substring match across customer/address/order // fields. Server-side search isn't needed since every row is loaded. const filteredRows = useMemo(() => { const wantStatus = String(currentStatus || '').toLowerCase(); const q = String(debouncedSearch || '') .trim() .toLowerCase(); return countSourceRows.filter((r) => { if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false; const s = String(r.orderstatus || '').toLowerCase(); if (wantStatus === 'other') { if (KNOWN_TAB_STATUSES.includes(s)) return false; } else if (wantStatus && s !== wantStatus) return false; if (q) { const hay = [ r.deliverycustomer, r.deliveryaddress, r.deliverysuburb, r.pickupcustomer, r.pickupaddress, r.pickupsuburb, r.orderid, r.tenantname, r.ridername, r.username ] .map((v) => String(v || '').toLowerCase()) .join(' '); if (!hay.includes(q)) return false; } return true; }); }, [countSourceRows, selectedBatch, currentStatus, debouncedSearch]); // A batch/search change re-scopes the list (tab changes already reset this // in handleChangetab), so page 1 is the only page that still means // anything. Sorting and page size are the operator's own choices. useEffect(() => { setTablePagination((p) => ({ ...p, pageIndex: 0 })); }, [selectedBatch, debouncedSearch]); // Moved above the columns useMemo below (was previously declared just // before the `errorMessage` early return, alongside tenantOptions etc.) — // the 'action' column's cell closes over it, and a useMemo's factory runs // synchronously the moment it's called, so it needs buildRowMenuItems to // already be initialized, not just declared later in the same function body. const buildRowMenuItems = (row) => { const items = []; if (row.orderstatus !== 'delivered') { items.push({ label: 'Notify Rider', icon: , onClick: () => notifyRiderMutation.mutate(row.milerprofileid) }); } if (['pending', 'accepted', 'arrived'].includes(row.orderstatus)) { items.push({ label: 'Change Rider', icon: , onClick: () => { if (appId == null) { opentoast('Please select a location first!', 'warning'); return; } setSelectedRow(row); setChangeDialogOpen(true); } }); } // Was gated to roleid 1/2 (staff-only), matching the reference app's // identical gate — opened to every login per explicit decision, so // tenant operators can reach the same Update Status fields staff // already could. items.push({ label: 'Update Status', icon: , onClick: () => { // PUT /admin/consignments/:id/status is consignment-scoped — it // only exists once the rider marks pickup-complete (doormile-flow.md). // Without a real consignmentid, updateDeliveryAPI's `?? deliveryid` // fallback was sending the BOOKING id where a consignment id is // required — a different id space, so the call failed against // the wrong (or nonexistent) resource. Combined with the mutation's // onSuccess only handling `res.success === true` with no else // branch, that failure was completely silent — dialog just sat // there looking like nothing happened. Block it here instead, // with a message that explains why, rather than letting the // submit silently no-op. if (!row.consignmentid) { opentoast("This order hasn't been picked up yet — status can only be updated once it becomes a consignment.", 'warning'); return; } setSelectedRow(row); setKms(row.kms); setCumulativeKms(row.cumulativekms); setDeliverylat(row.droplat); setDeliverylong(row.droplon); setNotes(row.notes); setDeliveryamount(row.deliveryamt ?? row.deliveryamount); setUpdateStatus(row.orderstatus || 'delivered'); setCurrentorder(row); setDialogopen(true); } }); if (row.orderstatus !== 'cancelled' && row.orderstatus !== 'delivered') { items.push({ label: 'Cancel Delivery', icon: , onClick: () => { setSelectedRow(row); setCancelDeliveryOpen(true); } }); } return items; }; // ==============================|| Desktop table model (TanStack Table v9) ||============================== // Same engine and anatomy as orders.js's table: explicit column defs, // sortable/resizable columns, a Columns visibility menu, page-scoped row // selection, and page-number Pagination instead of scroll-triggered // "load more". filteredRows is already the fully-loaded, fully-filtered // dataset (see countSourceRows' auto-drain above), so — like orders.js — // the table paginates a FULL in-memory array rather than a server page. const columns = useMemo( () => [ { id: 'select', size: 44, minSize: 44, enableSorting: false, enableResizing: false, enableHiding: false, meta: { label: 'Select' }, header: ({ table: t }) => ( t.toggleAllPageRowsSelected(!!checked)} /> ), cell: ({ row }) => ( row.toggleSelected(!!checked)} /> ) }, { id: 'serial', size: 54, minSize: 48, enableSorting: false, meta: { label: '#' }, header: () => ( # ), cell: ({ table: t, row }) => ( {String(tablePagination.pageIndex * tablePagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')} ) }, { id: 'status', accessorKey: 'orderstatus', size: 130, minSize: 110, meta: { label: 'Status' }, header: () => ( Status ), cell: ({ getValue, row }) => row.original.statusfromconsignment ? ( ) : ( ) }, { id: 'orderTenant', accessorFn: (row) => `${row.orderid} ${row.tenantname}`, size: 260, minSize: 210, meta: { label: 'Order & Tenant' }, header: () => ( Order & Tenant ), cell: ({ row }) => { const r = row.original; const ordered = parseDoormileTimestamp(r.orderdate); return ( {r.orderid} {r.tenantname} · {ordered.isValid() ? ordered.format('hh:mm A') : '—'} ); } }, { id: 'route', accessorFn: (row) => `${row.pickupcustomer} ${row.deliverycustomer}`, size: 320, minSize: 260, meta: { label: 'Route (Pickup → Drop)' }, header: () => ( Route (Pickup → Drop) ), cell: ({ row }) => { const r = row.original; const pickupAddr = r.pickupsuburb || r.pickuplocation || r.Pickupaddress || ''; const dropAddr = r.deliverysuburb || r.deliverylocation || r.deliveryaddress || ''; const routeTooltip = (
Pickup
Pickup: {r.pickupcustomer || 'Pickup'} {r.pickupcontactno ? `(${r.pickupcontactno})` : ''} {r.Pickupaddress || r.pickuplocation || 'No address'}
Drop
Drop: {r.deliverycustomer || 'Drop'} {r.deliverycontactno ? `(${r.deliverycontactno})` : ''} {r.deliveryaddress || r.deliverylocation || 'No address'}
); // hasTruncateTooltip={false} on every Text in this cell. // // Astryx's Text turns its own truncation tooltip ON by default as // soon as maxLines > 0, so each of these four lines was offering to // re-show its own clipped string on hover. Nested inside the route // Tooltip, that means hovering the address pops a bare address over // the pickup/drop card that already contains it — two tooltips // racing to explain the same cell. // // The route tooltip carries the full pickup AND drop address, so // nothing is lost by silencing the per-line ones. return ( Pickup {r.pickupcustomer || 'Pickup'} {pickupAddr ? `· ${pickupAddr}` : ''} Drop {r.deliverycustomer || 'Drop'} {dropAddr ? `· ${dropAddr}` : ''} ); } }, { id: 'rider', accessorKey: 'ridername', size: 190, minSize: 150, sortUndefined: 'last', meta: { label: 'Rider' }, header: () => ( Rider ), cell: ({ row }) => { const r = row.original; return r.ridername ? ( {r.ridername} ID #{r.userid} {r.ridercontact ? `· ${r.ridercontact}` : ''} ) : ( Unassigned ); } }, { id: 'timingRoute', accessorFn: (row) => row.expecteddeliverytime || row.kms, size: 140, minSize: 110, sortUndefined: 'last', meta: { label: 'ETA & Distance' }, header: () => ( ETA & Distance ), cell: ({ row }) => { const r = row.original; const eta = r.expecteddeliverytime ? parseDoormileTimestamp(r.expecteddeliverytime).format('hh:mm A') : null; const kms = Number(r.kms); return ( {eta ? `ETA ${eta}` : 'ETA —'} {kms ? ( {kms.toFixed(2)} km ) : ( — )} ); } }, { id: 'amountQty', accessorFn: (row) => Number(row.deliverycharges) || Number(row.deliveryamt), size: 130, minSize: 100, sortUndefined: 'last', meta: { label: 'Amount & Qty' }, header: () => ( Amount & Qty ), cell: ({ row }) => { const r = row.original; const amt = r.deliverycharges ?? r.deliveryamt; return ( {amt != null ? `₹ ${Number(amt).toFixed(2)}` : '—'} {r.Quantity ? `${r.Quantity} items` : '1 parcel'} ); } }, { id: 'notes', accessorKey: 'notes', size: 120, minSize: 90, enableSorting: false, meta: { label: 'Notes' }, header: () => ( Notes ), cell: ({ getValue }) => { const v = getValue(); return v ? ( {v} ) : ( — ); } }, { id: 'action', size: 90, minSize: 80, enableSorting: false, enableResizing: false, enableHiding: false, meta: { label: 'Action', align: 'right' }, header: () => ( Action ), cell: ({ row }) => { const r = row.original; const isOpen = productCollapse?.orderid === r?.orderid; return ( {r.deliverytype == 'C' && ( : } onClick={() => { if (isOpen) { setProductCollapse(null); setOrderHeaderId(null); } else { setProductCollapse(r); setOrderHeaderId(r.orderheaderid); } }} /> )} ); } } ], [tablePagination, buildRowMenuItems, productCollapse] ); // Two sources feed one visibility slice: whatever the operator toggled in // the Columns menu, plus the checkbox/action columns, which the ACTIVE TAB // owns (same pattern as orders.js's `select` column). const columnVisibility = useMemo( () => ({ ...userColumnVisibility, select: showSelect, action: showAction }), [userColumnVisibility, showSelect, showAction] ); const table = useTable({ features: DELIVERIES_TABLE_FEATURES, data: filteredRows, columns, getRowId: (row, index) => String(row.orderheaderid ?? `row-${index}`), columnResizeMode: 'onChange', state: { rowSelection, columnVisibility, sorting, pagination: tablePagination }, onRowSelectionChange: setRowSelection, onColumnVisibilityChange: setUserColumnVisibility, onSortingChange: setSorting, onPaginationChange: setTablePagination }); const colCount = table.getVisibleLeafColumns().length; // The distinct statuses that matched no tab, named so the fix is obvious. const unmappedStatuses = useMemo(() => { const seen = new Set(); countSourceRows.forEach((r) => { const s = String(r.orderstatus || '').toLowerCase(); if (s && !KNOWN_TAB_STATUSES.includes(s)) seen.add(s); }); return [...seen]; }, [countSourceRows]); // Counts per status, scoped to the selected batch. Keys mirror the legacy // *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical // (countData?.uncoveredLength → batchCounts.uncoveredLength). const batchCounts = useMemo(() => { const c = { uncoveredLength: 0, assignedLength: 0, otherLength: 0, arrivedLength: 0, pickedLength: 0, activeLength: 0, skippedLength: 0, coveredLength: 0, cancelLength: 0 }; countSourceRows.forEach((r) => { if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return; const s = String(r.orderstatus || '').toLowerCase(); switch (s) { case 'pending': c.uncoveredLength += 1; break; case 'accepted': case 'assigned': c.assignedLength += 1; break; case 'arrived': c.arrivedLength += 1; break; case 'picked': c.pickedLength += 1; break; case 'active': c.activeLength += 1; break; case 'skipped': c.skippedLength += 1; break; case 'delivered': c.coveredLength += 1; break; case 'cancelled': case 'canceled': c.cancelLength += 1; break; // Every status this page knows has a case above. Anything else — a // backend enum nobody has mapped yet — used to fall through here AND // fail every tab's equality filter, so the row was counted nowhere and // displayed nowhere: silent data loss, not an empty state. default: c.otherLength += 1; break; } }); return c; }, [countSourceRows, selectedBatch]); // Per-batch total (any status) for the dropdown badges. Computed once for // every batch + 'all' so each option in the menu can show its own count // without re-walking the list. Mirrors dispatch's batchCounts shape // (`Dispatch.js:1198–1206`) — totals here should match the per-batch numbers // visible on the dispatch page for the same date. const batchTotals = useMemo(() => { // `all` is the SUM of the three batch buckets — not the raw row count. // Rows that fall in a gap (8–9 AM, 12:30–4 PM, after 7 PM) or have no // expected delivery time belong to no batch, so they're excluded here too. This keeps // the "All Batches" badge equal to Morning + Afternoon + Evening // (e.g. 36 + 89 + 53 = 178) instead of over-reporting the un-bucketed total. const totals = { all: 0 }; BATCH_OPTIONS.forEach((b) => { if (b.id === 'all') return; totals[b.id] = 0; }); countSourceRows.forEach((r) => { const id = getRowBatchId(r); if (id) { totals[id] = (totals[id] || 0) + 1; totals.all += 1; } }); return totals; }, [countSourceRows]); // IntersectionObserver now watches the count-source query — that's the // single source of truth for both the table and the chip counts. When the // sentinel scrolls into view it nudges the next page of all-statuses // deliveries to keep the dataset complete. useEffect(() => { if (!countHasNext) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { countFetchNext(); } }, { root: document.querySelector('.dt-table-scroll'), rootMargin: '0px', threshold: 1.0 } ); if (loadMoreRef.current) observer.observe(loadMoreRef.current); return () => { if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); }; }, [countHasNext, countFetchNext]); /* ============================================= || fetchcount | ============================================= */ // `data` (fetchCountAPI's own count breakdown) is no longer read — the KPI // tiles above now derive from batchCounts/batchTotals instead (see the // comment there). This query still runs for isLoading/isError/refetch, // which other code below still depends on. const { isLoading: fetchCountIsLoading, isError: fetchCountIsError, error: fetchCountError, refetch: fetchCountRefetch } = useQuery({ queryKey: ['fetchCountData', appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid, tabstatus], queryFn: () => fetchCountAPI(appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid) }); // ==============================|| fetchRidersList ||============================== // const { data: ridersList = [], isLoading: riderListIsLoading, isError: ridersListIsError, error: ridersListError } = useQuery({ queryKey: ['ridersList', appId], // Unique key for caching & re-fetching // fetchRidersList now toasts + returns [] internally on failure (was // throwing after already toasting there too, which showed this same // error twice) — no onError needed here anymore. queryFn: fetchRidersList, enabled: appId != null }); /* ============================================= || updatedelivery | ============================================= */ const updateDeliveryMutation = useMutation({ mutationFn: (orderData) => updateDeliveryAPI(orderData), onSuccess: (res) => { if (res.success) { opentoast('Updated Successfully', 'success'); setDeliveryamount(''); setNotes(''); setDialogopen(false); fetchCountRefetch(); countSourceRefetch(); } else { // A 200 with success:false (the Doormile API's validation-failure // shape, same pattern as customers.js's saveMutation) had no else // branch here before — the dialog just sat open with zero feedback, // looking exactly like the button didn't work. opentoast(res.message || 'Failed to update delivery', 'error'); } }, onError: (err) => { opentoast(err.response?.data?.message || err.message || 'Failed to update delivery', 'error'); } }); const errorMessage = fetchCountIsError ? `Error fetching count summary: ${fetchCountError?.message}` : ridersListIsError ? `Error fetching riders: ${ridersListError?.message}` : fetchtenantsIsError ? `Error tenant list: ${fetchtenantsError?.message}` : fetchlocationsIsError ? `Error location list: ${fetchlocationsError?.message}` : null; if (errorMessage) { OpenToast(errorMessage, 'error', 2000); return null; // or return <> if inside a component } const tenantOptions = (tenantlist || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname })); const locationOptions = (locationlist || []).map((l) => ({ value: String(l.locationid), label: l.locationname ? `${l.locationname}${l.suburb ? ` (${l.suburb})` : ''}` : '' })); const riderOptions = (ridersList || []).map((r) => { const name = r?.displayname || r?.authname || ''; return { value: String(r.userid), label: r?.contactno ? `${name} (${r.contactno})` : name }; }); // Trims the redundant " Batch" off BATCH_OPTIONS' own label (which stays // full elsewhere, e.g. the empty-state message) — "Afternoon Batch" was // overflowing/wrapping inside the selector's compact trigger, squeezed to a // 200px FilterToolbar track alongside four other filters. // // The count is NOT in the label any more. It used to be appended as // "Evening · 0", which put a number in the closed trigger where it read as // part of the batch's name, and — on any batch with nothing in it — put a // bare "· 0" in the filter row. A count belongs beside the option it counts, // as a badge, and a zero is better said by absence than by a "0". const batchSelectorOptions = BATCH_OPTIONS.map((b) => ({ value: b.id, label: b.label.replace(/ Batch$/, '') })); // The count rides in the open menu only. `renderOption` receives the option // data, which carries `value` but not the total, so it is looked up here // rather than smuggled through the label. const renderBatchOption = (option) => { const total = batchTotals[option.value] ?? 0; return ( {option.label} {total > 0 && } ); }; const renderProductDetails = () => ( Product Details # Item Category Weight Declared Value {/* GET /admin/bookings has no product image/qty/tax breakdown — this renders the real "parcels" array instead of the old product-catalog shape. */} {orderdetails?.parcels?.map((parcel, idx2) => ( {String(idx2 + 1).padStart(2, '0')} {parcel?.itemdescription || 'Item'} {parcel?.itemcategory || '-'} {parcel?.weight ? `${parcel.weight}kg` : '-'} ₹ {(parcel?.declaredvalue || 0).toFixed(2)} ))} Delivery Charge ₹ {(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)}
); const emptyDescription = selectedBatch === 'all' ? `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders for this filter.` : `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch' }.`; return ( <> {(fetchCountIsLoading || countSourceIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading) && ( )} } /> {/* Status segment cards at the top of the page */} { const idx = STATUS_TABS.findIndex((t) => t.status === status); if (idx !== -1) handleChangetab(idx); }} tabs={[ ...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })), ...(batchCounts.otherLength > 0 ? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }] : []) ]} size="sm" hasDivider={false} hideHint={true} /> {/* Filters live OUTSIDE the table card. Inside it, the batch/date/tenant/location/rider controls read as part of the table's own chrome — a header band belonging to the rows — when they actually decide WHICH rows exist. Lifting them out makes the card start at the data, and the controls read as what they are: page-level filters acting on the card below. */}
v && setSelectedBatch(v)} placeholder="Batch" />
{ if (!v) { setTenantid(0); return; } const t = (tenantlist || []).find((x) => String(x.tenantid) === v); setTenantid(t?.tenantid || 0); }} placeholder="Select Tenant" hasSearch hasClear isDisabled={appId == null} disabledMessage="Please select your zone first!" />
{ if (!v) { setLocationid(0); return; } const l = (locationlist || []).find((x) => String(x.locationid) === v); setLocationid(l?.locationid || 0); }} placeholder="Select Location" hasSearch hasClear isDisabled={!tenantid} disabledMessage={appId == null ? 'Please select your Zone and Tenant first!' : 'Please select your Tenant first!'} />
setRiderid(v ? Number(v) : 0)} placeholder="Choose Rider" hasSearch hasClear isDisabled={riderListIsLoading || appId == null} disabledMessage="Please select your zone first!" />
{/* Columns menu — driven by the same `columnVisibility` slice the tab uses for select/action, so there is one source of truth for "is this column showing" (same pattern as orders.js). Only rendered for the desktop table. */} {!isMobile && ( }} menuWidth={200} placement="below" > {table .getAllLeafColumns() .filter((column) => column.getCanHide()) .map((column) => ( column.toggleVisibility(checked)} hasCloseOnSelect={false} /> ))} )}
{isMobile ? ( {filteredRows.length === 0 && !loading1 && !countSourceLoading && ( } /> )} {filteredRows.length === 0 && countSourceLoading && ( Loading deliveries… )} {filteredRows.map((row, index) => { const rowStatusMeta = getStatusMeta(row.orderstatus); const isSelected = !!deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); const isOpen = productCollapse?.orderid === row?.orderid; return ( {showSelect && ( { if (checked) { setDeliverylist([...deliverylist, { ...row, sno: deliverylist.length + 1 }]); } else { const res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); if (res) { const arr = deliverylist.filter((_, i) => i !== res.sno - 1); arr.forEach((val, i) => (val.sno = i + 1)); setDeliverylist(arr); } } }} /> )} {String(page * rowsPerPage + index + 1).padStart(2, '0')} {row.deliverytype == 'C' && ( : } onClick={() => { if (isOpen) { setProductCollapse(null); setOrderHeaderId(null); } else { setProductCollapse(row); setOrderHeaderId(row.orderheaderid); } }} /> )} {showAction && } {row.tenantname} {[row.tenantsuburb, row.applocation].filter(Boolean).join(' · ') || '—'} } > {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname} {row.orderid} · {row.deliveryid} {row.pickupcustomer || '—'} {row.pickupcontactno} {row.deliverycustomer || '—'} {row.deliverycontactno} {row.ridername ? ( {row.ridername} ID #{row.userid} · {row.ridercontact || '—'} ) : ( Unassigned )} {row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'} {row.notes && ( {row.notes} )} {isOpen &&
{renderProductDetails()}
}
); })} {countSourceRows?.length != 0 && (
{countIsFetchingNext || countHasNext ? ( ) : ( · End of list · )}
)}
) : ( <> {/* table-layout: fixed makes the declared column widths AUTHORITATIVE — same reasoning as orders.js's table. Widths come from header.getSize(), the columnSizing slice the drag handles write to. */} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const align = header.column.columnDef.meta?.align; const canSort = header.column.getCanSort(); const sorted = header.column.getIsSorted(); return ( { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); header.column.toggleSorting(); } } : undefined } style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined} > {flexRender(header.column.columnDef.header, header.getContext())} {canSort && (sorted === 'asc' ? ( ) : sorted === 'desc' ? ( ) : ( ))} {header.column.getCanResize() && (
{/* Page-number Pagination replaces the scroll-to-reveal sentinel — same swap orders.js made. filteredRows is the full, already- loaded dataset (see countSourceRows' auto-drain above), so this paginates an in-memory array, not a server page. */} {filteredRows.length > 0 && ( <> {`${tablePagination.pageIndex * tablePagination.pageSize + 1}–${Math.min( (tablePagination.pageIndex + 1) * tablePagination.pageSize, filteredRows.length )} of ${filteredRows.length} deliver${filteredRows.length === 1 ? 'y' : 'ies'}`} table.setPageIndex(nextPage - 1)} totalItems={filteredRows.length} pageSize={tablePagination.pageSize} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={(size) => table.setPageSize(size)} size="sm" label="Deliveries pagination" /> )} )}
{/* =============================== || cancel dialog || =============================== */} !next && setCancelDeliveryOpen(false)} width={420} purpose="required"> !next && setCancelDeliveryOpen(false)} startContent={ } /> } content={