From 8cc7cc75f9f019fbfc2731c026524d59a23734a4 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 28 May 2026 13:38:44 +0530 Subject: [PATCH] updates on the deliveries page and the order creation page --- src/pages/api/api.js | 24 +- src/pages/nearle/deliveries/deliveries.js | 555 +++++++++++++++++++--- src/pages/nearle/orders/orders.js | 308 ++++++++++-- 3 files changed, 769 insertions(+), 118 deletions(-) diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 1c86394..554ef54 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -152,13 +152,27 @@ export const finalCreatedeliveries = async (deliveryData) => { export const createAutomationDeliveries = async (variables) => { console.log('variables', variables); - // optimse the orders and auto rider assign - const response = await axios.post( + const absentRiders = Array.isArray(variables.absent_riders) ? variables.absent_riders : []; + + // 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 ? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}` - : `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`, - variables.selectedMode.value == 1 ? variables.deliveries : variables.data - ); + : `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`; + + 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; }; diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index bea7e25..031e27e 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -1,7 +1,7 @@ import logger from '../../../utils/logger'; import { enqueueSnackbar } from 'notistack'; 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 dayjs from 'dayjs'; var utc = require('dayjs/plugin/utc'); @@ -9,9 +9,15 @@ dayjs.extend(utc); import axios from 'axios'; import HoverSocialCard from 'components/cards/statistics/HoverSocialCard'; import { useTheme } from '@mui/material/styles'; -import { PiMapPinLineDuotone } from 'react-icons/pi'; -import { MdOutlineDateRange } from 'react-icons/md'; -import { MdOutlineDeliveryDining } from 'react-icons/md'; +import { + MdOutlineDateRange, + MdAccessTime, + MdAllInclusive, + MdLightMode, + MdWbSunny, + MdNightsStay, + MdCheck +} from 'react-icons/md'; import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; import { @@ -50,7 +56,8 @@ import { CircularProgress, Backdrop, MenuItem, - Menu + Menu, + Paper } from '@mui/material'; import MainCard from 'components/MainCard'; @@ -98,6 +105,84 @@ import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; 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 (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 `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 +// 8–9 AM / 12 PM–4 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)|| ================================================= const Deliveries = () => { const userid = localStorage.getItem('userid'); @@ -146,6 +231,10 @@ const Deliveries = () => { const [tenantValue, setTenantValue] = useState(null); const [locationValue, setLocationValue] = useState(null); 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'); useEffect(() => { @@ -182,6 +271,7 @@ const Deliveries = () => { setCancelDeliveryOpen(false); fetchCountRefetch(); // Refresh count data fetchDeliveriesRefetch(); // Refresh deliveries + countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips }, onError: (error) => { opentoast(error.message, 'error'); @@ -268,6 +358,7 @@ const Deliveries = () => { } fetchCountRefetch(); // Refresh count data fetchDeliveriesRefetch(); // Refresh deliveries + countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips notifyRiderMutation.mutate(selectedRider.userfcmtoken); }, onError: (err, { selectedRider, selectedRow }) => { @@ -382,16 +473,177 @@ const Deliveries = () => { }); 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(() => { - 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 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:1198–1206`) — 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( (entries) => { if (entries[0].isIntersecting) { - fetchNextPage(); + countFetchNext(); } }, { - root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer + root: document.querySelector('.MuiTableContainer-root'), rootMargin: '0px', threshold: 1.0 } @@ -400,13 +652,13 @@ const Deliveries = () => { return () => { if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); }; - }, [hasNextPage, fetchNextPage]); + }, [countHasNext, countFetchNext]); const handleScroll = (event) => { const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; if (scrollTop + clientHeight >= scrollHeight - 50) { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); + if (countHasNext && !countIsFetchingNext) { + countFetchNext(); } } }; @@ -476,6 +728,7 @@ const Deliveries = () => { setDialogopen(false); fetchDeliveriesRefetch(); fetchCountRefetch(); + countSourceRefetch(); } }, onError: (err) => { @@ -508,7 +761,7 @@ const Deliveries = () => { <> {(fetchCountIsLoading || fetchPercentageIsLoading || - fetchDeliveriesIsLoading || + countSourceIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading) && ( @@ -589,24 +842,197 @@ const Deliveries = () => { {/* ============================================= || orderFilter | ============================================= */} - {startdate && enddate ? ( - - - - - } - label={`Deliveries-${datestatus}`} - color="error" - variant="combined" - /> + + {/* Batch dropdown — replaces the legacy "Deliveries-{datestatus}" and + "Orders-All" pills. Filters the loaded rows by the slot the + operator picks; defaults to 'all'. Mirrors the dispatch page's + batch model. UI mirrors the polished Absent Riders dropdown from + the orders page — custom Paper, per-batch color/icon, selected + indicator. */} + {(() => { + const activeBatch = BATCH_OPTIONS.find((b) => b.id === selectedBatch) || BATCH_OPTIONS[0]; + const ActiveIcon = BATCH_ICONS[activeBatch.iconKey] || MdAccessTime; + return ( + 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) => ( + + )} + ListboxProps={{ sx: { py: 0, maxHeight: 360 } }} + renderOption={(props, option, { selected }) => { + const Icon = BATCH_ICONS[option.iconKey] || MdAccessTime; + const total = batchTotals[option.id] ?? 0; + return ( +
  • + + + + + + {option.label} + + + {option.range} + + + {/* 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. */} + 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 && ( + + + + )} +
  • + ); + }} + renderInput={(params) => ( + + + + +
    + ) + }} + 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 && ( @@ -621,24 +1047,11 @@ const Deliveries = () => { onClick={() => setOpen(true)} variant="combined" color="warning" - sx={{ maxWidth: '100%', cursor: 'pointer' }} // to avoid overflow + sx={{ maxWidth: '100%', cursor: 'pointer' }} /> + )} - - - - } - label={locaName} - color="info" - variant="combined" - sx={{ maxWidth: '100%' }} - /> -
    - ) : ( - - )} +
    { label="Assigned" icon={ { label="Accepted" icon={ { label="Arrived" icon={ { label="Picked" icon={ { label="Active" icon={ { label="Skipped" icon={ { label="Delivered" icon={ { label="Cancelled" icon={ { {tabstatus == 'Created' && ( 0 && deliverylist.length != rows.length} + indeterminate={deliverylist.length > 0 && deliverylist.length != filteredRows.length} onChange={(e) => { if (e.target.checked) { - setDeliverylist([...rows]); + setDeliverylist([...filteredRows]); } else { setDeliverylist([]); } }} - checked={deliverylist.length == rows.length} + checked={deliverylist.length > 0 && deliverylist.length == filteredRows.length} /> )} @@ -944,17 +1357,33 @@ const Deliveries = () => { )} - {(loading1 || fetchDeliveriesIsLoading) && } + {(loading1 || countSourceIsLoading) && } - {rows.length == 0 && !loading1 && ( + {filteredRows.length == 0 && !loading1 && !countSourceLoading && ( <> - {/* */} - + b.id === selectedBatch)?.label || 'this batch'}` + } + styles={{ description: { color: theme.palette.error.main } }} + /> )} - {rows.map((row, index) => { + {filteredRows.length == 0 && countSourceLoading && ( + + + + + Loading deliveries… + + + + )} + {filteredRows.map((row, index) => { return ( <> { ); })} - {rows?.length != 0 && ( + {countSourceRows?.length != 0 && (
    - {isFetchingNextPage ? : hasNextPage ? : 'No More Deliveries'} + {countIsFetchingNext ? : countHasNext ? : 'No More Deliveries'}
    diff --git a/src/pages/nearle/orders/orders.js b/src/pages/nearle/orders/orders.js index 4a5719a..d7ddc23 100644 --- a/src/pages/nearle/orders/orders.js +++ b/src/pages/nearle/orders/orders.js @@ -13,7 +13,7 @@ import Loader from 'components/Loader'; import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material'; 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 DateFilterDialog from 'components/DateFilterDialog'; 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 BoltIcon from '@mui/icons-material/Bolt'; 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 { GiProfit } from 'react-icons/gi'; import { IoReload } from 'react-icons/io5'; @@ -74,7 +70,8 @@ import { Divider, AccordionDetails, AccordionSummary, - Accordion + Accordion, + Paper } from '@mui/material'; import { @@ -152,6 +149,10 @@ const Orders = () => { { label: 'Auto', value: 2 } ]; 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 @@ -549,6 +550,18 @@ const Orders = () => { deliverylocation: val.deliverysuburb })); 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) { // manual assign createDeliveryMutation.mutate({ @@ -561,7 +574,8 @@ const Orders = () => { // mode 1 -> bike , 2 -> auto deliveries: selectedMode.value == 1 ? deliveryData : { body: deliveryData }, hypertuning_params: tune || null, - selectedMode + selectedMode, + absent_riders: absentRidersPayload // reshuffle: 'joshi' }); } else { @@ -574,7 +588,8 @@ const Orders = () => { pay_type: 'hourly', // options: "hourly", "daily", or "orders" base_pay: 300.0, strategy: 'multi_trip' - } + }, + absent_riders: absentRidersPayload }, selectedMode }); @@ -634,6 +649,8 @@ const Orders = () => { setAssignDialog(false); setRider(null); setPayment(null); + setSelectedMode(null); + setAbsentRiders([]); }; 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 && ( + 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) => ( + + )} + ListboxProps={{ sx: { py: 0, maxHeight: 340 } }} + noOptionsText={ + + + + No riders to show for this location + + + } + 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 ( + + {initials} + + } + 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 ( +
  • + + + {initials} + + + + {name || `Rider #${option.userid}`} + + + ID #{option.userid} + {option.contactno ? ` · ${option.contactno}` : ''} + + + {selected && ( + + )} +
  • + ); + }} + renderInput={(params) => ( + + + {absentRiders.length > 0 && ( + + )} + {params.InputProps.startAdornment} + + ) + }} + 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 } + }} + /> + )} + -