updates on the deliveries page and the order creation page

This commit is contained in:
2026-05-28 13:38:44 +05:30
parent c77f3c96b7
commit 8cc7cc75f9
3 changed files with 769 additions and 118 deletions

View File

@@ -152,13 +152,27 @@ export const finalCreatedeliveries = async (deliveryData) => {
export const createAutomationDeliveries = async (variables) => { export const createAutomationDeliveries = async (variables) => {
console.log('variables', variables); console.log('variables', variables);
// optimse the orders and auto rider assign const absentRiders = Array.isArray(variables.absent_riders) ? variables.absent_riders : [];
const response = await axios.post(
// Bike mode (routes.workolik) historically accepted just the deliveries
// array as the body. To carry the operator's "Absent Riders" picks
// through to the AI assignment, we now wrap the body as
// { deliveries: [...], absent_riders: [...] }
// for that endpoint. Auto mode (routemate) already uses a structured
// body via `variables.data`, so we just merge absent_riders into it.
const url =
variables.selectedMode.value == 1 variables.selectedMode.value == 1
? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}` ? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}`
: `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`, : `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`;
variables.selectedMode.value == 1 ? variables.deliveries : variables.data
); const body =
variables.selectedMode.value == 1
? { deliveries: variables.deliveries, absent_riders: absentRiders }
: { ...(variables.data || {}), absent_riders: absentRiders };
console.log('createAutomationDeliveries body', body);
const response = await axios.post(url, body);
return response.data; return response.data;
}; };

View File

@@ -1,7 +1,7 @@
import logger from '../../../utils/logger'; import logger from '../../../utils/logger';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { DeleteFilled, EditOutlined } from '@ant-design/icons'; import { DeleteFilled, EditOutlined } from '@ant-design/icons';
import { useState, useEffect, Fragment, useRef } from 'react'; import { useState, useEffect, Fragment, useRef, useMemo } from 'react';
import { Empty } from 'antd'; import { Empty } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
@@ -9,9 +9,15 @@ dayjs.extend(utc);
import axios from 'axios'; import axios from 'axios';
import HoverSocialCard from 'components/cards/statistics/HoverSocialCard'; import HoverSocialCard from 'components/cards/statistics/HoverSocialCard';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { PiMapPinLineDuotone } from 'react-icons/pi'; import {
import { MdOutlineDateRange } from 'react-icons/md'; MdOutlineDateRange,
import { MdOutlineDeliveryDining } from 'react-icons/md'; MdAccessTime,
MdAllInclusive,
MdLightMode,
MdWbSunny,
MdNightsStay,
MdCheck
} from 'react-icons/md';
import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query';
import { import {
@@ -50,7 +56,8 @@ import {
CircularProgress, CircularProgress,
Backdrop, Backdrop,
MenuItem, MenuItem,
Menu Menu,
Paper
} from '@mui/material'; } from '@mui/material';
import MainCard from 'components/MainCard'; import MainCard from 'components/MainCard';
@@ -98,6 +105,84 @@ import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
// 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).
const BATCH_OPTIONS = [
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
{ id: 'afternoon', label: 'Afternoon Batch', range: '9 AM to 12 PM', color: '#f59e0b', iconKey: 'afternoon', startHour: 9, endHour: 12 },
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
];
// Per-batch icon components — kept separate from BATCH_OPTIONS so the option
// objects stay serializable / printable. Looked up at render time by iconKey.
const BATCH_ICONS = {
all: MdAllInclusive,
morning: MdLightMode,
afternoon: MdWbSunny,
evening: MdNightsStay
};
// 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 (89 AM, 12 PM4
// 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 `assigntime` only — matches the spec ("bucketed by assigntime")
// and the dispatch page's default time-field selection (`Dispatch.js:763`
// initialises `selectedTimeField` to `'assigned'`, whose `keys` is just
// `['assigntime']`). The previous priority cascade started with
// `deliverytime`, which mis-bucketed delivered orders into the wave they
// were *completed* in rather than the wave they were *assigned to* — so an
// order assigned at 07:00 (Morning) but delivered at 11:00 fell into the
// 89 AM / 12 PM4 PM gap and silently disappeared from every batch on this
// page while still showing up on dispatch's Morning Batch. Rows without an
// `assigntime` (unassigned pending orders) return null and intentionally
// don't bucket — matches dispatch.
const BATCH_TIME_KEYS = ['assigntime'];
// 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.
const d = dayjs(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;
};
// ================================================= || deliveries (initial point)|| ================================================= // ================================================= || deliveries (initial point)|| =================================================
const Deliveries = () => { const Deliveries = () => {
const userid = localStorage.getItem('userid'); const userid = localStorage.getItem('userid');
@@ -146,6 +231,10 @@ const Deliveries = () => {
const [tenantValue, setTenantValue] = useState(null); const [tenantValue, setTenantValue] = useState(null);
const [locationValue, setLocationValue] = useState(null); const [locationValue, setLocationValue] = useState(null);
const [riderid, setRiderid] = 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);
const roleid = localStorage.getItem('roleid'); const roleid = localStorage.getItem('roleid');
useEffect(() => { useEffect(() => {
@@ -182,6 +271,7 @@ const Deliveries = () => {
setCancelDeliveryOpen(false); setCancelDeliveryOpen(false);
fetchCountRefetch(); // Refresh count data fetchCountRefetch(); // Refresh count data
fetchDeliveriesRefetch(); // Refresh deliveries fetchDeliveriesRefetch(); // Refresh deliveries
countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips
}, },
onError: (error) => { onError: (error) => {
opentoast(error.message, 'error'); opentoast(error.message, 'error');
@@ -268,6 +358,7 @@ const Deliveries = () => {
} }
fetchCountRefetch(); // Refresh count data fetchCountRefetch(); // Refresh count data
fetchDeliveriesRefetch(); // Refresh deliveries fetchDeliveriesRefetch(); // Refresh deliveries
countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips
notifyRiderMutation.mutate(selectedRider.userfcmtoken); notifyRiderMutation.mutate(selectedRider.userfcmtoken);
}, },
onError: (err, { selectedRider, selectedRow }) => { onError: (err, { selectedRider, selectedRow }) => {
@@ -382,16 +473,177 @@ const Deliveries = () => {
}); });
const rows = deliveriesData?.pages.flatMap((page) => page.rows) || []; const rows = deliveriesData?.pages.flatMap((page) => page.rows) || [];
// (filteredRows is defined below, after countSourceRows / countSourceLoading
// are in scope — they're the single source of truth for both the table and
// the per-status chip counts.)
// 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
});
const countSourceRows = useMemo(
() => (countSourceData?.pages || []).flatMap((p) => p.rows || []),
[countSourceData]
);
useEffect(() => { useEffect(() => {
if (!hasNextPage) return; 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 && 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]);
// 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,
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;
default:
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:11981206`) — totals here should match the per-batch numbers
// visible on the dispatch page for the same date.
const batchTotals = useMemo(() => {
const totals = { all: countSourceRows.length };
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;
});
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( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
fetchNextPage(); countFetchNext();
} }
}, },
{ {
root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer root: document.querySelector('.MuiTableContainer-root'),
rootMargin: '0px', rootMargin: '0px',
threshold: 1.0 threshold: 1.0
} }
@@ -400,13 +652,13 @@ const Deliveries = () => {
return () => { return () => {
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
}; };
}, [hasNextPage, fetchNextPage]); }, [countHasNext, countFetchNext]);
const handleScroll = (event) => { const handleScroll = (event) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 50) { if (scrollTop + clientHeight >= scrollHeight - 50) {
if (hasNextPage && !isFetchingNextPage) { if (countHasNext && !countIsFetchingNext) {
fetchNextPage(); countFetchNext();
} }
} }
}; };
@@ -476,6 +728,7 @@ const Deliveries = () => {
setDialogopen(false); setDialogopen(false);
fetchDeliveriesRefetch(); fetchDeliveriesRefetch();
fetchCountRefetch(); fetchCountRefetch();
countSourceRefetch();
} }
}, },
onError: (err) => { onError: (err) => {
@@ -508,7 +761,7 @@ const Deliveries = () => {
<> <>
{(fetchCountIsLoading || {(fetchCountIsLoading ||
fetchPercentageIsLoading || fetchPercentageIsLoading ||
fetchDeliveriesIsLoading || countSourceIsLoading ||
fetchtenantsIsLoading || fetchtenantsIsLoading ||
fetchlocationsIsLoading || fetchlocationsIsLoading ||
riderListIsLoading) && ( riderListIsLoading) && (
@@ -589,24 +842,197 @@ const Deliveries = () => {
{/* ============================================= || orderFilter | ============================================= */} {/* ============================================= || orderFilter | ============================================= */}
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={3} sx={{ flexWrap: 'wrap', my: 2 }}> <Stack direction="row" justifyContent="space-between" alignItems="center" gap={3} sx={{ flexWrap: 'wrap', my: 2 }}>
{startdate && enddate ? ( <Stack
<Stack direction="row"
direction="row" flexWrap="wrap"
flexWrap="wrap" // ✅ allow wrapping gap={1.5}
gap={1.5} // ✅ space between items when wrapped alignItems="center"
alignItems="center" // optional, for vertical alignment >
> {/* Batch dropdown — replaces the legacy "Deliveries-{datestatus}" and
<Chip "Orders-All" pills. Filters the loaded rows by the slot the
avatar={ operator picks; defaults to 'all'. Mirrors the dispatch page's
<Avatar sx={{ backgroundColor: 'transparent' }}> batch model. UI mirrors the polished Absent Riders dropdown from
<MdOutlineDeliveryDining fontSize="30px" style={{ color: 'red' }} /> the orders page — custom Paper, per-batch color/icon, selected
</Avatar> indicator. */}
} {(() => {
label={`Deliveries-${datestatus}`} const activeBatch = BATCH_OPTIONS.find((b) => b.id === selectedBatch) || BATCH_OPTIONS[0];
color="error" const ActiveIcon = BATCH_ICONS[activeBatch.iconKey] || MdAccessTime;
variant="combined" return (
/> <Autocomplete
disableClearable
size="small"
// Click-only dropdown: typing is disabled (readOnly input below),
// but the field still opens the menu on click. openOnFocus +
// selectOnFocus give the operator one-click access; the search
// filter built into Autocomplete is suppressed since there's
// nothing to search across only 4 options.
openOnFocus
selectOnFocus
filterOptions={(opts) => opts}
options={BATCH_OPTIONS}
value={activeBatch}
onChange={(e, val) => val && setSelectedBatch(val.id)}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
PaperComponent={(paperProps) => (
<Paper
{...paperProps}
sx={{
mt: 0.75,
borderRadius: 2,
boxShadow: '0 14px 40px rgba(15, 23, 42, 0.18)',
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden'
}}
/>
)}
ListboxProps={{ sx: { py: 0, maxHeight: 360 } }}
renderOption={(props, option, { selected }) => {
const Icon = BATCH_ICONS[option.iconKey] || MdAccessTime;
const total = batchTotals[option.id] ?? 0;
return (
<li
{...props}
key={option.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
borderBottom: '1px solid #f1f5f9',
backgroundColor: selected ? `${option.color}10` : 'transparent',
transition: 'background-color 0.15s'
}}
>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: selected ? option.color : `${option.color}18`,
color: selected ? '#fff' : option.color,
transition: 'background-color 0.15s, color 0.15s'
}}
>
<Icon size={20} />
</Avatar>
<Stack direction="column" spacing={0} flex={1} minWidth={0}>
<Typography
variant="body2"
fontWeight={700}
color="#0f172a"
noWrap
>
{option.label}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{option.range}
</Typography>
</Stack>
{/* Per-batch total — same number you see next to each
batch on the dispatch page. Lets the operator
eyeball-compare without summing the per-status
chip counts on the tabs. */}
<Chip
size="small"
label={total}
sx={{
minWidth: 36,
height: 22,
fontWeight: 800,
fontSize: 11,
bgcolor: total > 0 ? `${option.color}18` : '#f1f5f9',
color: total > 0 ? option.color : '#94a3b8',
border: `1px solid ${total > 0 ? option.color + '55' : '#e2e8f0'}`,
'& .MuiChip-label': { px: 0.75 }
}}
/>
{selected && (
<Avatar
sx={{
width: 22,
height: 22,
bgcolor: option.color,
color: '#fff'
}}
>
<MdCheck size={14} />
</Avatar>
)}
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
placeholder="Batch"
// readOnly disables typing; the input still receives focus
// and clicks, so the popup still opens. caretColor:transparent
// hides the blinking caret that focus would normally show.
inputProps={{
...params.inputProps,
readOnly: true,
style: { ...(params.inputProps?.style || {}), cursor: 'pointer', caretColor: 'transparent' }
}}
InputProps={{
...params.InputProps,
startAdornment: (
<Stack
direction="row"
alignItems="center"
spacing={0.75}
sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}
>
<Avatar
sx={{
width: 24,
height: 24,
bgcolor: `${activeBatch.color}18`,
color: activeBatch.color
}}
>
<ActiveIcon size={14} />
</Avatar>
</Stack>
)
}}
sx={{
minWidth: 240,
cursor: 'pointer',
'& .MuiOutlinedInput-root': {
borderRadius: '999px',
bgcolor: `${activeBatch.color}08`,
fontWeight: 700,
color: activeBatch.color,
paddingRight: '8px',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.2s',
'& fieldset': {
borderColor: `${activeBatch.color}55`,
borderWidth: 1.5
},
'&:hover fieldset': { borderColor: activeBatch.color },
'&.Mui-focused': {
boxShadow: `0 0 0 3px ${activeBatch.color}26`
},
'&.Mui-focused fieldset': {
borderColor: activeBatch.color,
borderWidth: 2
}
},
'& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': {
color: activeBatch.color
}
}}
/>
)}
/>
);
})()}
{/* Date range chip — opens the date picker. Stays visible only when
a date range is set (matches prior behavior). */}
{startdate && enddate && (
<Chip <Chip
avatar={ avatar={
<Avatar sx={{ backgroundColor: 'transparent' }}> <Avatar sx={{ backgroundColor: 'transparent' }}>
@@ -621,24 +1047,11 @@ const Deliveries = () => {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
variant="combined" variant="combined"
color="warning" color="warning"
sx={{ maxWidth: '100%', cursor: 'pointer' }} // to avoid overflow sx={{ maxWidth: '100%', cursor: 'pointer' }}
/> />
)}
<Chip </Stack>
avatar={
<Avatar sx={{ backgroundColor: 'transparent' }}>
<PiMapPinLineDuotone fontSize="30px" style={{ color: '#00bcd4' }} />
</Avatar>
}
label={locaName}
color="info"
variant="combined"
sx={{ maxWidth: '100%' }}
/>
</Stack>
) : (
<Chip label="Orders-All" color="primary" variant="light" size="small" />
)}
<Autocomplete <Autocomplete
disablePortal disablePortal
@@ -754,7 +1167,7 @@ const Deliveries = () => {
label="Assigned" label="Assigned"
icon={ icon={
<Chip <Chip
label={countData?.uncoveredLength || 0} label={batchCounts.uncoveredLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -767,7 +1180,7 @@ const Deliveries = () => {
label="Accepted" label="Accepted"
icon={ icon={
<Chip <Chip
label={countData?.assignedLength || 0} label={batchCounts.assignedLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -780,7 +1193,7 @@ const Deliveries = () => {
label="Arrived" label="Arrived"
icon={ icon={
<Chip <Chip
label={countData?.arrivedLength || 0} label={batchCounts.arrivedLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -794,7 +1207,7 @@ const Deliveries = () => {
label="Picked" label="Picked"
icon={ icon={
<Chip <Chip
label={countData?.pickedLength || 0} label={batchCounts.pickedLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -807,7 +1220,7 @@ const Deliveries = () => {
label="Active" label="Active"
icon={ icon={
<Chip <Chip
label={countData?.activeLength || 0} label={batchCounts.activeLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -820,7 +1233,7 @@ const Deliveries = () => {
label="Skipped" label="Skipped"
icon={ icon={
<Chip <Chip
label={countData?.skippedLength || 0} label={batchCounts.skippedLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -834,7 +1247,7 @@ const Deliveries = () => {
label="Delivered" label="Delivered"
icon={ icon={
<Chip <Chip
label={countData?.coveredLength || 0} label={batchCounts.coveredLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -848,7 +1261,7 @@ const Deliveries = () => {
label="Cancelled" label="Cancelled"
icon={ icon={
<Chip <Chip
label={countData?.cancelLength || 0} label={batchCounts.cancelLength}
color="primary" color="primary"
variant="light" variant="light"
size="small" size="small"
@@ -901,15 +1314,15 @@ const Deliveries = () => {
{tabstatus == 'Created' && ( {tabstatus == 'Created' && (
<TableCell sx={{ whiteSpace: 'nowrap' }}> <TableCell sx={{ whiteSpace: 'nowrap' }}>
<Checkbox <Checkbox
indeterminate={deliverylist.length > 0 && deliverylist.length != rows.length} indeterminate={deliverylist.length > 0 && deliverylist.length != filteredRows.length}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setDeliverylist([...rows]); setDeliverylist([...filteredRows]);
} else { } else {
setDeliverylist([]); setDeliverylist([]);
} }
}} }}
checked={deliverylist.length == rows.length} checked={deliverylist.length > 0 && deliverylist.length == filteredRows.length}
/> />
</TableCell> </TableCell>
)} )}
@@ -944,17 +1357,33 @@ const Deliveries = () => {
)} )}
</TableRow> </TableRow>
</TableHead> </TableHead>
{(loading1 || fetchDeliveriesIsLoading) && <OrdersTableSkeleton col={8} />} {(loading1 || countSourceIsLoading) && <OrdersTableSkeleton col={8} />}
<TableBody> <TableBody>
{rows.length == 0 && !loading1 && ( {filteredRows.length == 0 && !loading1 && !countSourceLoading && (
<> <>
<TableCell colSpan={14}> <TableCell colSpan={14}>
{/* <Stack width={'100%'} direction={'row'} justifyContent={'center'}> */} <Empty
<Empty description={`No ${tabstatus} Orders`} styles={{ description: { color: theme.palette.error.main } }} /> description={
selectedBatch === 'all'
? `No ${tabstatus} Orders`
: `No ${tabstatus} Orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'}`
}
styles={{ description: { color: theme.palette.error.main } }}
/>
</TableCell> </TableCell>
</> </>
)} )}
{rows.map((row, index) => { {filteredRows.length == 0 && countSourceLoading && (
<TableRow>
<TableCell colSpan={14} sx={{ textAlign: 'center', py: 4 }}>
<LoaderWithImage />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Loading deliveries
</Typography>
</TableCell>
</TableRow>
)}
{filteredRows.map((row, index) => {
return ( return (
<> <>
<TableRow <TableRow
@@ -1399,11 +1828,11 @@ const Deliveries = () => {
</> </>
); );
})} })}
{rows?.length != 0 && ( {countSourceRows?.length != 0 && (
<TableRow> <TableRow>
<TableCell colSpan={15} rowSpan={3}> <TableCell colSpan={15} rowSpan={3}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}> <div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage ? <LoaderWithImage /> : hasNextPage ? <LoaderWithImage /> : 'No More Deliveries'} {countIsFetchingNext ? <LoaderWithImage /> : countHasNext ? <LoaderWithImage /> : 'No More Deliveries'}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -13,7 +13,7 @@ import Loader from 'components/Loader';
import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material'; import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material';
import { PiMapPinLineDuotone } from 'react-icons/pi'; import { PiMapPinLineDuotone } from 'react-icons/pi';
import { MdOutlineDateRange } from 'react-icons/md'; import { MdOutlineDateRange, MdPersonOff, MdEventBusy } from 'react-icons/md';
import { VscArchive } from 'react-icons/vsc'; import { VscArchive } from 'react-icons/vsc';
import DateFilterDialog from 'components/DateFilterDialog'; import DateFilterDialog from 'components/DateFilterDialog';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
@@ -29,11 +29,7 @@ import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlin
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import BoltIcon from '@mui/icons-material/Bolt'; import BoltIcon from '@mui/icons-material/Bolt';
import { ArrowRightAltOutlined } from '@mui/icons-material'; import { ArrowRightAltOutlined } from '@mui/icons-material';
import { DashboardFilled } from '@ant-design/icons';
import { MdDirectionsBike } from 'react-icons/md';
import { FaMapLocationDot } from 'react-icons/fa6';
import { HiOutlineArrowLeft } from 'react-icons/hi'; import { HiOutlineArrowLeft } from 'react-icons/hi';
import { GiProfit } from 'react-icons/gi';
import { IoReload } from 'react-icons/io5'; import { IoReload } from 'react-icons/io5';
@@ -74,7 +70,8 @@ import {
Divider, Divider,
AccordionDetails, AccordionDetails,
AccordionSummary, AccordionSummary,
Accordion Accordion,
Paper
} from '@mui/material'; } from '@mui/material';
import { import {
@@ -152,6 +149,10 @@ const Orders = () => {
{ label: 'Auto', value: 2 } { label: 'Auto', value: 2 }
]; ];
const [selectedMode, setSelectedMode] = useState(null); const [selectedMode, setSelectedMode] = useState(null);
// Riders the operator has marked as absent for today — passed to the AI
// assign API so its allocation skips them. Optional: the AI run still
// works with an empty list.
const [absentRiders, setAbsentRiders] = useState([]);
// when orders are selected to show popup to stop unwanted reload // when orders are selected to show popup to stop unwanted reload
@@ -549,6 +550,18 @@ const Orders = () => {
deliverylocation: val.deliverysuburb deliverylocation: val.deliverysuburb
})); }));
console.log('deliveryData', deliveryData); console.log('deliveryData', deliveryData);
// Normalize the operator's "Absent Riders" picks into the {userid, username}
// shape the AI assign API expects. The API returns firstname/lastname, so
// we join them into a single `username` string (with a fallback to the
// raw `username` field when names are missing).
const absentRidersPayload = (absentRiders || []).map((r) => ({
userid: r.userid,
username:
`${r.firstname || ''} ${r.lastname || ''}`.trim() ||
r.username ||
`Rider ${r.userid}`
}));
if (aiModeRef.current == 0) { if (aiModeRef.current == 0) {
// manual assign // manual assign
createDeliveryMutation.mutate({ createDeliveryMutation.mutate({
@@ -561,7 +574,8 @@ const Orders = () => {
// mode 1 -> bike , 2 -> auto // mode 1 -> bike , 2 -> auto
deliveries: selectedMode.value == 1 ? deliveryData : { body: deliveryData }, deliveries: selectedMode.value == 1 ? deliveryData : { body: deliveryData },
hypertuning_params: tune || null, hypertuning_params: tune || null,
selectedMode selectedMode,
absent_riders: absentRidersPayload
// reshuffle: 'joshi' // reshuffle: 'joshi'
}); });
} else { } else {
@@ -574,7 +588,8 @@ const Orders = () => {
pay_type: 'hourly', // options: "hourly", "daily", or "orders" pay_type: 'hourly', // options: "hourly", "daily", or "orders"
base_pay: 300.0, base_pay: 300.0,
strategy: 'multi_trip' strategy: 'multi_trip'
} },
absent_riders: absentRidersPayload
}, },
selectedMode selectedMode
}); });
@@ -634,6 +649,8 @@ const Orders = () => {
setAssignDialog(false); setAssignDialog(false);
setRider(null); setRider(null);
setPayment(null); setPayment(null);
setSelectedMode(null);
setAbsentRiders([]);
}; };
const errorMessage = fetchpercentageIsError const errorMessage = fetchpercentageIsError
@@ -1716,6 +1733,235 @@ const Orders = () => {
/> />
)} )}
{/* Absent Riders multi-select — optional. Operator picks the
riders who are off today; the AI assign API receives their
{userid, username} under `absent_riders` and skips them
during allocation. Leaving it empty is valid: the AI run
proceeds against every rider in the location. */}
{aiModeRef.current == 1 && (
<Autocomplete
multiple
fullWidth={false}
options={ridersList || []}
loading={ridersListLoading}
disableCloseOnSelect
limitTags={2}
value={absentRiders}
onChange={(event, newValue) => setAbsentRiders(newValue)}
getOptionLabel={(option) => {
const name = `${option.firstname || ''} ${option.lastname || ''}`.trim();
return name || `Rider #${option.userid}`;
}}
isOptionEqualToValue={(option, value) => option.userid === value.userid}
PaperComponent={(paperProps) => (
<Paper
{...paperProps}
sx={{
mt: 0.75,
borderRadius: 2,
boxShadow: '0 14px 40px rgba(15, 23, 42, 0.18)',
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden'
}}
/>
)}
ListboxProps={{ sx: { py: 0, maxHeight: 340 } }}
noOptionsText={
<Stack alignItems="center" py={2} spacing={1}>
<MdEventBusy size={26} color="#94a3b8" />
<Typography variant="caption" color="text.secondary">
No riders to show for this location
</Typography>
</Stack>
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const name =
`${option.firstname || ''} ${option.lastname || ''}`.trim() ||
`Rider #${option.userid}`;
const initials =
(name.match(/\b\w/g) || []).slice(0, 2).join('').toUpperCase() || '?';
const tagProps = getTagProps({ index });
return (
<Chip
{...tagProps}
key={option.userid}
size="small"
avatar={
<Avatar
sx={{
bgcolor: '#fed7aa',
color: '#9a3412',
fontSize: 10,
fontWeight: 800
}}
>
{initials}
</Avatar>
}
label={name}
sx={{
height: 26,
bgcolor: '#fff7ed',
color: '#9a3412',
border: '1px solid',
borderColor: '#fdba74',
fontWeight: 600,
fontSize: 12,
'& .MuiChip-label': { px: 0.75 },
'& .MuiChip-deleteIcon': {
color: '#c2410c',
fontSize: 16,
'&:hover': { color: '#7c2d12' }
}
}}
/>
);
})
}
renderOption={(props, option, { selected }) => {
const name = `${option.firstname || ''} ${option.lastname || ''}`.trim();
const initials =
(name.match(/\b\w/g) || []).slice(0, 2).join('').toUpperCase() || '?';
return (
<li
{...props}
key={option.userid}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid #f1f5f9',
backgroundColor: selected ? '#fff7ed' : 'transparent'
}}
>
<Checkbox
size="small"
checked={selected}
sx={{
p: 0.5,
color: '#fb923c',
'&.Mui-checked': { color: '#ea580c' }
}}
/>
<Avatar
sx={{
width: 34,
height: 34,
bgcolor: selected ? '#fb923c' : '#f1f5f9',
color: selected ? '#fff' : '#475569',
fontSize: 12,
fontWeight: 800,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{initials}
</Avatar>
<Stack direction="column" spacing={0} flex={1} minWidth={0}>
<Typography
variant="body2"
fontWeight={700}
color="#0f172a"
noWrap
>
{name || `Rider #${option.userid}`}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
ID #{option.userid}
{option.contactno ? ` · ${option.contactno}` : ''}
</Typography>
</Stack>
{selected && (
<Chip
size="small"
label="Absent"
sx={{
height: 18,
fontSize: 9,
fontWeight: 800,
letterSpacing: 0.3,
bgcolor: '#ea580c',
color: '#fff',
'& .MuiChip-label': { px: 0.75 }
}}
/>
)}
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label="Absent Riders"
placeholder={absentRiders.length ? '' : 'Pick riders unavailable today'}
InputLabelProps={{ shrink: true }}
InputProps={{
...params.InputProps,
startAdornment: (
<Stack
direction="row"
alignItems="center"
spacing={0.75}
sx={{ pl: 0.5, mr: 0.25 }}
>
<MdPersonOff size={18} color="#ea580c" />
{absentRiders.length > 0 && (
<Chip
size="small"
label={absentRiders.length}
sx={{
height: 18,
minWidth: 22,
fontSize: 10,
fontWeight: 800,
bgcolor: '#ea580c',
color: '#fff',
'& .MuiChip-label': { px: 0.5 }
}}
/>
)}
{params.InputProps.startAdornment}
</Stack>
)
}}
sx={{
minWidth: 300,
maxWidth: 440,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
bgcolor: '#fffbf5',
transition: 'border-color 0.15s, box-shadow 0.15s',
'& fieldset': {
borderColor: '#fdba74',
borderWidth: 1.5
},
'&:hover fieldset': { borderColor: '#fb923c' },
'&.Mui-focused': {
boxShadow: '0 0 0 3px rgba(251, 146, 60, 0.18)'
},
'&.Mui-focused fieldset': {
borderColor: '#ea580c',
borderWidth: 2
}
},
'& .MuiInputLabel-root': {
fontWeight: 700,
fontSize: 13,
color: '#9a3412',
'&.Mui-focused': { color: '#9a3412' }
}
}}
/>
)}
sx={{
'& .MuiAutocomplete-tag': { my: 0.25 },
'& .MuiAutocomplete-endAdornment': { right: 8 }
}}
/>
)}
<Button <Button
color={'primary'} color={'primary'}
disabled={!selectedMode && aiModeRef.current == 1} disabled={!selectedMode && aiModeRef.current == 1}
@@ -2108,7 +2354,7 @@ const Orders = () => {
<CircularLoader color="inherit" /> <CircularLoader color="inherit" />
</Backdrop> </Backdrop>
} }
<DialogTitle> <DialogTitle sx={{ py: 1.25 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{}}> <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{}}>
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="Back to orders" placement="top"> <Tooltip title="Back to orders" placement="top">
@@ -2190,51 +2436,13 @@ const Orders = () => {
/> />
</Stack> </Stack>
</Stack> </Stack>
<Stack sx={{ my: 2 }}>
<Grid container spacing={2}>
<Grid item xs={6} sm={6} md={3}>
<HoverSocialCard
secondary={metaData?.total_orders}
primary={'Orders'}
percentage={<DashboardFilled />}
color={theme.palette.success.main}
sx={{ cursor: 'pointer' }}
/>
</Grid>
<Grid item xs={6} sm={6} md={3}>
<HoverSocialCard
secondary={metaData?.utilized_riders}
primary={'Riders'}
percentage={<MdDirectionsBike />}
color={theme.palette.warning.main}
/>
</Grid>
<Grid item xs={6} sm={6} md={3}>
<HoverSocialCard
secondary={zoneData?.length}
primary={'Zones'}
percentage={<FaMapLocationDot />}
color={theme.palette.info.main}
/>
</Grid>
<Grid item xs={6} sm={6} md={3}>
<HoverSocialCard
secondary={metaData?.total_profit}
primary={'Profit'}
percentage={<GiProfit />}
color={theme.palette.error.main}
/>
</Grid>
</Grid>
</Stack>
</DialogTitle> </DialogTitle>
<DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }}> <DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }}>
{dispatchPreviewData && <Dispatch data={dispatchPreviewData} embedded />} {dispatchPreviewData && <Dispatch data={dispatchPreviewData} embedded />}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ px: 2, py: 1.25 }}>
<Stack display={'flex'} flexDirection={'row'} gap={2} alignItems={'center'} justifyContent={'end'} sx={{ p: 2 }}> <Stack direction="row" gap={2} alignItems="center" justifyContent="end">
<Button <Button
sx={{}}
variant="contained" variant="contained"
color="secondary" color="secondary"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
@@ -2245,7 +2453,7 @@ const Orders = () => {
> >
Back Back
</Button> </Button>
<Button sx={{ my: 2 }} variant="contained" onClick={handleFinalCreateDelivery}> <Button variant="contained" onClick={handleFinalCreateDelivery}>
Assign Orders Assign Orders
</Button> </Button>
</Stack> </Stack>