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 = (
);
// 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 (
{r.pickupcustomer || 'Pickup'}
{pickupAddr ? `· ${pickupAddr}` : ''}
{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
#ItemCategoryWeightDeclared 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"
/>
}
onClick={() => setOpen(true)}
/>
{
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}
/>
))}
)}
{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. */}
)}
);
})}
))}
{(loading1 || countSourceIsLoading) && }
{filteredRows.length === 0 && !loading1 && !countSourceLoading && (
{/* Same fix as orders.js: in `fill` mode the row/cell
only shrink-to-fit their content — TableScroll's
outer div caps at maxHeight but doesn't stretch to
it — so a lone empty-state row sat at the top of a
lot of leftover space instead of centering in it. */}