Files
Doormilexpress_console/src/pages/nearle/deliveries/deliveries.js

2206 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <div> 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 ? (
<Tooltip content={tooltip}>
<Badge variant={variant} label={label} />
</Tooltip>
) : (
<Badge variant={variant} label={label} />
);
// 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 <status> 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: <MdNotificationsActive size={16} />,
onClick: () => notifyRiderMutation.mutate(row.milerprofileid)
});
}
if (['pending', 'accepted', 'arrived'].includes(row.orderstatus)) {
items.push({
label: 'Change Rider',
icon: <MdDirectionsBike size={16} />,
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: <MdCheckCircle size={16} />,
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: <MdCancel size={16} />,
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 }) => (
<CheckboxInput
label="Select all"
isLabelHidden
value={t.getIsAllPageRowsSelected() ? true : t.getIsSomePageRowsSelected() ? 'indeterminate' : false}
onChange={(checked) => t.toggleAllPageRowsSelected(!!checked)}
/>
),
cell: ({ row }) => (
<CheckboxInput
label="Select row"
isLabelHidden
value={row.getIsSelected()}
onChange={(checked) => row.toggleSelected(!!checked)}
/>
)
},
{
id: 'serial',
size: 54,
minSize: 48,
enableSorting: false,
meta: { label: '#' },
header: () => (
<Text type="label" weight="bold" color="secondary">
#
</Text>
),
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary">
{String(tablePagination.pageIndex * tablePagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'status',
accessorKey: 'orderstatus',
size: 130,
minSize: 110,
meta: { label: 'Status' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Status
</Text>
),
cell: ({ getValue, row }) =>
row.original.statusfromconsignment ? (
<Tooltip
content={`From consignment #${row.original.consignmentid} (${row.original.consignmentstatus}). The booking itself stays at Converted_To_Consignment once it has been picked up.`}
>
<span>
<StatusBadge status={getValue()} />
</span>
</Tooltip>
) : (
<StatusBadge status={getValue()} />
)
},
{
id: 'orderTenant',
accessorFn: (row) => `${row.orderid} ${row.tenantname}`,
size: 260,
minSize: 210,
meta: { label: 'Order & Tenant' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Order & Tenant
</Text>
),
cell: ({ row }) => {
const r = row.original;
const ordered = parseDoormileTimestamp(r.orderdate);
return (
<VStack gap={0.5} padding={0} minWidth={0}>
<Tooltip content={`Order ID: ${r.orderid} · Hub: ${r.locationname || '—'}`}>
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary, letterSpacing: -0.2 }}>
{r.orderid}
</Text>
</Tooltip>
<HStack gap={1} vAlign="center" minWidth={0}>
<Tooltip content={r.tenantadress ? `${r.tenantname} · ${r.tenantadress}` : r.tenantname}>
<Text weight="medium" color="secondary" maxLines={1} style={{ fontSize: 12 }}>
{r.tenantname}
</Text>
</Tooltip>
<Text type="supporting" color="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
· {ordered.isValid() ? ordered.format('hh:mm A') : '—'}
</Text>
</HStack>
</VStack>
);
}
},
{
id: 'route',
accessorFn: (row) => `${row.pickupcustomer} ${row.deliverycustomer}`,
size: 320,
minSize: 260,
meta: { label: 'Route (Pickup → Drop)' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Route (Pickup → Drop)
</Text>
),
cell: ({ row }) => {
const r = row.original;
const pickupAddr = r.pickupsuburb || r.pickuplocation || r.Pickupaddress || '';
const dropAddr = r.deliverysuburb || r.deliverylocation || r.deliveryaddress || '';
const routeTooltip = (
<div style={{ textAlign: 'left', minWidth: 200, maxWidth: 300, whiteSpace: 'normal', color: '#fff' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', marginBottom: '8px' }}>
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontWeight: 'bold', fontSize: '12px' }}>Pickup: {r.pickupcustomer || 'Pickup'} {r.pickupcontactno ? `(${r.pickupcontactno})` : ''}</span>
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.Pickupaddress || r.pickuplocation || 'No address'}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
<img src="/drop-icon.png" alt="Drop" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontWeight: 'bold', fontSize: '12px' }}>Drop: {r.deliverycustomer || 'Drop'} {r.deliverycontactno ? `(${r.deliverycontactno})` : ''}</span>
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.deliveryaddress || r.deliverylocation || 'No address'}</span>
</div>
</div>
</div>
);
// 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 (
<Tooltip content={routeTooltip}>
<VStack gap={0.5} padding={0} minWidth={0} style={{ cursor: 'pointer' }}>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 16, height: 16, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} hasTruncateTooltip={false} style={{ fontSize: 12, flexShrink: 0 }}>
{r.pickupcustomer || 'Pickup'}
</Text>
<Text type="supporting" color="secondary" maxLines={1} hasTruncateTooltip={false} style={{ fontSize: 11 }}>
{pickupAddr ? `· ${pickupAddr}` : ''}
</Text>
</HStack>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/drop-icon.png" alt="Drop" style={{ width: 17, height: 17, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} hasTruncateTooltip={false} style={{ fontSize: 12, flexShrink: 0 }}>
{r.deliverycustomer || 'Drop'}
</Text>
<Text type="supporting" color="secondary" maxLines={1} hasTruncateTooltip={false} style={{ fontSize: 11 }}>
{dropAddr ? `· ${dropAddr}` : ''}
</Text>
</HStack>
</VStack>
</Tooltip>
);
}
},
{
id: 'rider',
accessorKey: 'ridername',
size: 190,
minSize: 150,
sortUndefined: 'last',
meta: { label: 'Rider' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Rider
</Text>
),
cell: ({ row }) => {
const r = row.original;
return r.ridername ? (
<HStack gap={1.5} vAlign="center" minWidth={0}>
<AccentAvatar color="#8b5cf6" size={28}>
<MdDirectionsBike size={14} />
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}>
{r.ridername}
</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
ID #{r.userid} {r.ridercontact ? `· ${r.ridercontact}` : ''}
</Text>
</VStack>
</HStack>
) : (
<Text type="supporting" weight="medium" color="secondary" style={{ fontStyle: 'italic' }}>
Unassigned
</Text>
);
}
},
{
id: 'timingRoute',
accessorFn: (row) => row.expecteddeliverytime || row.kms,
size: 140,
minSize: 110,
sortUndefined: 'last',
meta: { label: 'ETA & Distance' },
header: () => (
<Text type="label" weight="bold" color="secondary">
ETA & Distance
</Text>
),
cell: ({ row }) => {
const r = row.original;
const eta = r.expecteddeliverytime ? parseDoormileTimestamp(r.expecteddeliverytime).format('hh:mm A') : null;
const kms = Number(r.kms);
return (
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="bold" style={{ fontSize: 12 }}>
{eta ? `ETA ${eta}` : 'ETA —'}
</Text>
{kms ? (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{kms.toFixed(2)} km
</Text>
) : (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
—
</Text>
)}
</VStack>
);
}
},
{
id: 'amountQty',
accessorFn: (row) => Number(row.deliverycharges) || Number(row.deliveryamt),
size: 130,
minSize: 100,
sortUndefined: 'last',
meta: { label: 'Amount & Qty' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Amount & Qty
</Text>
),
cell: ({ row }) => {
const r = row.original;
const amt = r.deliverycharges ?? r.deliveryamt;
return (
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="bold" style={{ fontSize: 13, color: DT.textPrimary }}>
{amt != null ? `₹ ${Number(amt).toFixed(2)}` : '—'}
</Text>
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{r.Quantity ? `${r.Quantity} items` : '1 parcel'}
</Text>
</VStack>
);
}
},
{
id: 'notes',
accessorKey: 'notes',
size: 120,
minSize: 90,
enableSorting: false,
meta: { label: 'Notes' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Notes
</Text>
),
cell: ({ getValue }) => {
const v = getValue();
return v ? (
<Tooltip content={v}>
<Text
type="supporting"
color="secondary"
style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}
>
{v}
</Text>
</Tooltip>
) : (
<Text type="supporting" color="secondary">
—
</Text>
);
}
},
{
id: 'action',
size: 90,
minSize: 80,
enableSorting: false,
enableResizing: false,
enableHiding: false,
meta: { label: 'Action', align: 'right' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Action
</Text>
),
cell: ({ row }) => {
const r = row.original;
const isOpen = productCollapse?.orderid === r?.orderid;
return (
<HStack gap={0.75} justify="end">
{r.deliverytype == 'C' && (
<IconButton
label="Product details"
tooltip="Product details"
size="sm"
variant="ghost"
icon={isOpen ? <MdKeyboardArrowUp size={16} /> : <MdKeyboardArrowDown size={16} />}
onClick={() => {
if (isOpen) {
setProductCollapse(null);
setOrderHeaderId(null);
} else {
setProductCollapse(r);
setOrderHeaderId(r.orderheaderid);
}
}}
/>
)}
<MoreMenu label="Actions" items={buildRowMenuItems(r)} size="sm" />
</HStack>
);
}
}
],
[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 (
<HStack gap={2} vAlign="center" justify="between" style={{ width: '100%', minWidth: 0 }}>
<Text>{option.label}</Text>
{total > 0 && <Badge variant="neutral" label={total} />}
</HStack>
);
};
const renderProductDetails = () => (
<Card padding={0} elevation="none" style={{ margin: 16 }}>
<HStack gap={2} vAlign="center" padding={2} style={{ borderBottom: `1px solid ${DT.divider}`, background: tint(BRAND) }}>
<AccentAvatar color={BRAND} selected size={28}>
<MdInventory2 size={14} />
</AccentAvatar>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.5, textTransform: 'uppercase' }}>
Product Details
</Text>
</HStack>
<TableScroll minWidth={700}>
<Table density="compact" dividers="rows">
<TableHeader>
<TableRow>
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
<TableHeaderCell>Item</TableHeaderCell>
<TableHeaderCell>Category</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'right' }}>Weight</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'right' }}>Declared Value</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{/* 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) => (
<TableRow key={idx2}>
<TableCell>
<Text type="supporting" weight="bold" color="secondary">
{String(idx2 + 1).padStart(2, '0')}
</Text>
</TableCell>
<TableCell>
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={BRAND} size={28}>
<MdInventory2 size={13} />
</AccentAvatar>
<Text weight="semibold" maxLines={1}>
{parcel?.itemdescription || 'Item'}
</Text>
</HStack>
</TableCell>
<TableCell>{parcel?.itemcategory || '-'}</TableCell>
<TableCell style={{ textAlign: 'right' }}>{parcel?.weight ? `${parcel.weight}kg` : '-'}</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Text weight="bold">₹ {(parcel?.declaredvalue || 0).toFixed(2)}</Text>
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={4} style={{ textAlign: 'right' }}>
<Text type="supporting" weight="bold" color="secondary">
Delivery Charge
</Text>
</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Text weight="bold" style={{ color: '#10b981' }}>
₹ {(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)}
</Text>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableScroll>
</Card>
);
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) && (
<Loader />
)}
<PageShell>
<PageHeader
title="Deliveries"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
setPage={setPage}
placeholder="Select Zone"
/>
}
/>
{/* Status segment cards at the top of the page */}
<StatusTabs
value={currentStatus}
onChange={(status) => {
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. */}
<div
className="dt-deliveries-toolbar"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '10px',
padding: '0',
width: '100%',
boxSizing: 'border-box'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '10px',
flex: '1 1 auto',
minWidth: 0
}}
>
<div style={{ flex: '1 1 130px', minWidth: 120, maxWidth: 180 }}>
<Selector
label="Batch"
isLabelHidden
options={batchSelectorOptions}
renderOption={renderBatchOption}
value={selectedBatch}
onChange={(v) => v && setSelectedBatch(v)}
placeholder="Batch"
/>
</div>
<div style={{ flexShrink: 0 }}>
<Button
label={
startdate && enddate ? `${dayjs(startdate).format('DD MMM')} – ${dayjs(enddate).format('DD MMM')}` : 'Select date range'
}
variant="secondary"
icon={<MdOutlineDateRange size={16} />}
onClick={() => setOpen(true)}
/>
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector
label="Tenant"
isLabelHidden
options={tenantOptions}
value={tenantid ? String(tenantid) : ''}
onChange={(v) => {
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!"
/>
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector
label="Location"
isLabelHidden
options={locationOptions}
value={locationid ? String(locationid) : ''}
onChange={(v) => {
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!'}
/>
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector
label="Rider"
isLabelHidden
options={riderOptions}
value={riderid ? String(riderid) : ''}
onChange={(v) => setRiderid(v ? Number(v) : 0)}
placeholder="Choose Rider"
hasSearch
hasClear
isDisabled={riderListIsLoading || appId == null}
disabledMessage="Please select your zone first!"
/>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
flex: '0 1 auto',
minWidth: 260,
justifyContent: 'flex-end'
}}
>
<div style={{ width: '100%', minWidth: 180, maxWidth: 260 }}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</div>
{/* 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 && (
<DropdownMenu
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
menuWidth={200}
placement="below"
>
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
label={column.columnDef.meta?.label ?? column.id}
value={column.getIsVisible()}
onChange={(checked) => column.toggleVisibility(checked)}
hasCloseOnSelect={false}
/>
))}
</DropdownMenu>
)}
</div>
</div>
<DataCard>
{isMobile ? (
<MobileCardList scroll>
{filteredRows.length === 0 && !loading1 && !countSourceLoading && (
<EmptyState title="No deliveries to show" description={emptyDescription} icon={<MdInventory2 size={28} />} />
)}
{filteredRows.length === 0 && countSourceLoading && (
<VStack vAlign="center" padding={4}>
<LoaderWithImage />
<Text type="supporting" color="secondary" style={{ marginTop: 8 }}>
Loading deliveries…
</Text>
</VStack>
)}
{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 (
<MobileCard
key={row.orderheaderid ?? `${row.tenantname}-${index}`}
accent={rowStatusMeta.color}
selected={isSelected}
header={
<VStack gap={1.5} padding={0}>
<HStack justify="between" vAlign="center" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}>
{showSelect && (
<CheckboxInput
label="Select row"
isLabelHidden
value={isSelected}
onChange={(checked) => {
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);
}
}
}}
/>
)}
<Text type="supporting" weight="bold" color="secondary">
{String(page * rowsPerPage + index + 1).padStart(2, '0')}
</Text>
<StatusBadge status={row.orderstatus} />
</HStack>
<HStack gap={0.75}>
{row.deliverytype == 'C' && (
<IconButton
label="Product details"
tooltip="Product details"
size="sm"
variant="ghost"
icon={isOpen ? <MdKeyboardArrowUp size={16} /> : <MdKeyboardArrowDown size={16} />}
onClick={() => {
if (isOpen) {
setProductCollapse(null);
setOrderHeaderId(null);
} else {
setProductCollapse(row);
setOrderHeaderId(row.orderheaderid);
}
}}
/>
)}
{showAction && <MoreMenu label="Actions" items={buildRowMenuItems(row)} size="sm" />}
</HStack>
</HStack>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{row.tenantname}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{[row.tenantsuburb, row.applocation].filter(Boolean).join(' · ') || '—'}
</Text>
</VStack>
</VStack>
}
>
<MobileFieldGrid>
<MobileField label="Order / Location" full>
<Text weight="semibold" maxLines={1}>
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Text>
<Text type="supporting" color="secondary">
{row.orderid} · {row.deliveryid}
</Text>
</MobileField>
<MobileField label="Pickup">
<Text weight="semibold" maxLines={1}>
{row.pickupcustomer || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.pickupcontactno}
</Text>
</MobileField>
<MobileField label="Drop">
<Text weight="semibold" maxLines={1}>
{row.deliverycustomer || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.deliverycontactno}
</Text>
</MobileField>
<MobileField label="Rider" full>
{row.ridername ? (
<HStack gap={1.5} vAlign="center">
<AccentAvatar color="#8b5cf6" size={24}>
<MdDirectionsBike size={13} />
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}>
{row.ridername}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
ID #{row.userid} · {row.ridercontact || '—'}
</Text>
</VStack>
</HStack>
) : (
<Text type="supporting" weight="bold" color="secondary">
Unassigned
</Text>
)}
</MobileField>
<MobileField
label="ETA"
value={row.expecteddeliverytime ? parseDoormileTimestamp(row.expecteddeliverytime).format('hh:mm A') : '—'}
/>
<MobileField label="Transit">
<MetricPill variant="cyan" label={`${row.transitminutes || 0}m`} />
</MobileField>
<MobileField label="Kms · plan / act">
<HStack gap={1} wrap="wrap">
<MetricPill variant="red" label={`${Number(row.kms || 0).toFixed(2)} km`} />
</HStack>
</MobileField>
<MobileField label="Amount · chg / amt">
<HStack gap={1} wrap="wrap">
<MetricPill variant="red" label={`₹ ${row.deliverycharges?.toFixed(2) ?? '0.00'}`} />
<MetricPill variant="green" label={`₹ ${row.deliveryamt?.toFixed(2) ?? '0.00'}`} />
</HStack>
</MobileField>
<MobileField label="Qty" value={row.Quantity || '—'} />
<MobileField label="COD">
<Text weight="bold" style={{ color: row.collectionamt ? '#ef4444' : DT.textMuted }}>
{row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'}
</Text>
</MobileField>
<MobileField label="Step" value={row.step || '—'} />
{row.notes && (
<MobileField label="Notes" full>
<Text type="supporting" color="secondary">
{row.notes}
</Text>
</MobileField>
)}
</MobileFieldGrid>
{isOpen && <div style={{ marginTop: 'var(--spacing-6)' }}>{renderProductDetails()}</div>}
</MobileCard>
);
})}
{countSourceRows?.length != 0 && (
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{countIsFetchingNext || countHasNext ? (
<LoaderWithImage />
) : (
<Text type="supporting" weight="bold" color="secondary">
· End of list ·
</Text>
)}
</div>
)}
</MobileCardList>
) : (
<>
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
<TableScroll minWidth={table.getTotalSize()} maxHeight="100%" flush>
{/* 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
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Deliveries"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} isHeaderRow>
{headerGroup.headers.map((header) => {
const align = header.column.columnDef.meta?.align;
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
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' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{(loading1 || countSourceIsLoading) && <TableLoader rows={8} columns={colCount} />}
{filteredRows.length === 0 && !loading1 && !countSourceLoading && (
<TableRow>
{/* 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. */}
<TableCell colSpan={colCount}>
<Center style={{ minHeight: 420 }}>
<EmptyState title="No deliveries to show" description={emptyDescription} icon={<MdInventory2 size={28} />} />
</Center>
</TableCell>
</TableRow>
)}
{filteredRows.length === 0 && countSourceLoading && (
<TableRow>
<TableCell colSpan={colCount} style={{ textAlign: 'center', padding: 32 }}>
<LoaderWithImage />
<Text type="supporting" color="secondary" style={{ display: 'block', marginTop: 8 }}>
Loading deliveries…
</Text>
</TableCell>
</TableRow>
)}
{table.getRowModel().rows.map((row) => {
const isOpen = productCollapse?.orderid === row.original?.orderid;
return (
<Fragment key={row.id}>
<TableRow style={{ backgroundColor: row.getIsSelected() ? tint(BRAND) : undefined }}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} style={{ textAlign: cell.column.columnDef.meta?.align }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: DT.surfaceAlt }}>
{renderProductDetails()}
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</TableScroll>
</VStack>
{/* 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 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${tablePagination.pageIndex * tablePagination.pageSize + 1}–${Math.min(
(tablePagination.pageIndex + 1) * tablePagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} deliver${filteredRows.length === 1 ? 'y' : 'ies'}`}
</Text>
<Pagination
page={tablePagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={filteredRows.length}
pageSize={tablePagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Deliveries pagination"
/>
</HStack>
</>
)}
</>
)}
</DataCard>
</PageShell>
{/* =============================== || cancel dialog || =============================== */}
<Dialog isOpen={cancelDeliveryOpen} onOpenChange={(next) => !next && setCancelDeliveryOpen(false)} width={420} purpose="required">
<Layout
header={
<DialogHeader
title="Cancel Delivery?"
subtitle="This action will cancel the delivery and notify the rider. Please share a brief reason."
onOpenChange={(next) => !next && setCancelDeliveryOpen(false)}
startContent={
<AccentAvatar color="#ef4444" selected size={36}>
<MdCancel size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<TextArea
label="Reason for cancellation"
isLabelHidden
placeholder="Reason for cancellation"
value={cancelFeed}
onChange={setCancelFeed}
/>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Keep Delivery" variant="secondary" onClick={() => setCancelDeliveryOpen(false)} />
<Button
label="Yes, Cancel"
variant="destructive"
onClick={() => {
if (cancelFeed == '') {
opentoast('Enter FeedBack', 'error');
} else {
cancelDelivery({ selectedRow, cancelFeed });
}
cancelridernotification();
}}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
{/* =============================== || change rider dialog || =============================== */}
<Dialog isOpen={changeDialogOpen} onOpenChange={(next) => !next && setChangeDialogOpen(false)} width={480} purpose="form">
<Layout
header={
<DialogHeader
title="Change Rider"
subtitle="Assign this delivery to a different rider"
onOpenChange={(next) => !next && setChangeDialogOpen(false)}
startContent={
<AccentAvatar color="#8b5cf6" selected size={36}>
<MdDirectionsBike size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<Selector
label="Rider"
isLabelHidden
options={riderOptions}
value={selectedRider ? String(selectedRider.userid) : ''}
onChange={(v) => {
const rider = (ridersList || []).find((r) => String(r.userid) === v) || null;
setSelectedRider(rider);
logger.debug('Rider selected in dropdown:', rider ? `${rider.firstname} ${rider.lastname}` : 'None');
}}
placeholder="Choose Rider"
hasSearch
/>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setChangeDialogOpen(false)} />
<Button
label="Change Rider"
variant="primary"
isLoading={loading1}
onClick={() => {
setLoading1(true);
logger.info(
`Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${selectedRider?.displayname || selectedRider?.authname
}`
);
changeRiderMutation.mutate({ selectedRider, selectedRow });
}}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
{/* =============================== || Date filter Dialog || =============================== */}
<Dialog isOpen={open} onOpenChange={(next) => !next && setOpen(false)} width={640} purpose="form">
<DialogHeader
title="Date Range"
subtitle="Pick a window to filter the deliveries"
onOpenChange={(next) => !next && setOpen(false)}
/>
<VStack gap={4} padding={4}>
<DateRangePicker
open={open}
toggle={() => setOpen(!open)}
id="daterange1"
// So the fields open showing the window already in force, rather
// than blank on an actively filtered table.
value={{ startDate: startdate || undefined, endDate: enddate || undefined }}
onChange={(range) => {
if (range.label === 'All') {
setStartdate('');
setEnddate('');
setOpen(false);
} else {
setStartdate(dayjs(range.startDate).format('YYYY-MM-DD'));
setEnddate(dayjs(range.endDate).format('YYYY-MM-DD'));
}
}}
definedRanges={[
{ label: 'Today', startDate: new Date(), endDate: new Date() },
{ label: 'Yesterday', startDate: addDays(new Date(), -1), endDate: addDays(new Date(), -1) },
{ label: 'Tomorrow', startDate: addDays(new Date(), +1), endDate: addDays(new Date(), +1) },
{ label: 'This Week', startDate: startOfWeek(new Date()), endDate: endOfWeek(new Date()) },
{ label: 'Last Week', startDate: startOfWeek(addWeeks(new Date(), -1)), endDate: endOfWeek(addWeeks(new Date(), -1)) },
{ label: 'Last 7 Days', startDate: addWeeks(new Date(), -1), endDate: new Date() },
{ label: 'This Month', startDate: startOfMonth(new Date()), endDate: endOfMonth(new Date()) },
{ label: 'Last Month', startDate: startOfMonth(addMonths(new Date(), -1)), endDate: endOfMonth(addMonths(new Date(), -1)) }
]}
/>
<HStack justify="end">
<Button label="Apply" variant="primary" onClick={okclicked} />
</HStack>
</VStack>
</Dialog>
{/* =============================== || Update Delivery Dialog || =============================== */}
<Dialog isOpen={dialogopen} onOpenChange={(next) => !next && dialogclose()} width={560} purpose="form">
<Layout
header={
<DialogHeader
title="Update Delivery Status"
subtitle={currentorder?.orderid ? `Order #${currentorder.orderid}` : 'Modify delivery details'}
onOpenChange={(next) => !next && dialogclose()}
startContent={
<AccentAvatar color="#10b981" selected size={36}>
<MdCheckCircle size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={2}>
<NumberInput label="KMs" value={kms === '' ? undefined : Number(kms)} onChange={(v) => setKms(v ?? '')} />
<NumberInput
label="Actual KMs"
value={cumulativekms === '' || cumulativekms == null ? undefined : Number(cumulativekms)}
onChange={(v) => setCumulativeKms(v ?? '')}
/>
<TextInput label="Delivery Latitude" value={String(deliverylat ?? '')} onChange={setDeliverylat} />
<TextInput label="Delivery Longitude" value={String(deliverylong ?? '')} onChange={setDeliverylong} />
</Grid>
<NumberInput
label="Amount"
value={deliveryamount === '' || deliveryamount == null ? undefined : Number(deliveryamount)}
onChange={(v) => setDeliveryamount(v ?? '')}
/>
<Selector
label="Status"
// 'picked' has to be here even though nothing sets it from
// this dialog: the dialog opens with the row's CURRENT status
// selected, and Converted_To_Consignment now classifies as
// picked — an option list without it would open blank on
// exactly the rows this dialog is reachable from (it needs a
// consignmentid, which is what being picked means).
options={['pending', 'accepted', 'started', 'arrived', 'picked', 'delivered', 'cancelled'].map((s) => ({
value: s,
label: getStatusMeta(s).label
}))}
value={updateStatus}
onChange={setUpdateStatus}
/>
<TextArea label="Notes" value={notes || ''} onChange={setNotes} />
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Close" variant="secondary" onClick={dialogclose} />
<Button
label="Update"
variant="primary"
isLoading={updateDeliveryMutation.isPending}
onClick={() => {
if (!kms) {
opentoast('Fill Kms', 'warning');
} else if (!cumulativekms) {
opentoast('Fill Actual Kms', 'warning');
} else if (!deliverylat) {
opentoast('Fill Delivery Latitude', 'warning');
} else if (!deliverylong) {
opentoast('Fill Delivery Longitude', 'warning');
} else if (!deliveryamount) {
opentoast('Fill Delivery Amount', 'warning');
} else {
updateDeliveryMutation.mutate({
deliveryid: currentorder.deliveryid,
consignmentid: currentorder.consignmentid,
orderheaderid: currentorder.orderheaderid,
orderstatus: updateStatus,
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
deliverylat,
deliverylong,
kms,
cumulativekms,
deliveryamt: parseFloat(deliveryamount),
notes,
userid: +selectedRow.userid
});
}
}}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
</>
);
};
export default Deliveries;