import * as React from 'react'; import { useState, useEffect, useRef, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Paper, Stack, Typography, Table, TableCell, TableBody, TableHead, IconButton, TableRow, Tooltip, TableContainer, Backdrop, Collapse, Grid, Box, Skeleton, useMediaQuery, ToggleButtonGroup, ToggleButton, Dialog, DialogTitle, DialogContent, DialogActions, Button, Autocomplete, TextField } from '@mui/material'; import { useTheme } from '@mui/material/styles'; var utc = require('dayjs/plugin/utc'); import dayjs from 'dayjs'; dayjs.extend(utc); import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { MdMyLocation, MdCheckCircle, MdCancel, MdGroups, MdOutlineGroups, MdOutlineCheckCircle, MdOutlineCancel, MdEdit, MdKeyboardArrowDown, MdKeyboardArrowUp, MdLocationOn, MdBatteryStd, MdPowerSettingsNew, MdSpeed, MdGpsFixed, MdAccessTime, MdInventory2, MdTwoWheeler, MdArrowForward, MdDelete, MdBlock, MdDirectionsCar } from 'react-icons/md'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import PageHeader from 'components/nearle_components/PageHeader'; import StatCard from 'components/nearle_components/StatCard'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import CircularLoader from 'components/CircularLoader'; import { fetchAllRiders, getallridersummary } from 'pages/api/api'; import { getMilers, blockMiler, assignMilerVehicle, getVehicles } from 'pages/api/doormileApi'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import { OpenToast } from 'components/third-party/OpenToast'; import RiderSubstitution from './RiderSubstitution'; // ============================================================================ // Design tokens — shared with the deliveries / tenants / customers pages so // every surface (header, KPI tiles, table, badges, dialog) speaks the same // visual language. Brand purple `#C01227` is the canonical primary; status // colours are semantic and distinct from the brand. // ============================================================================ const DT = { radiusPill: 999, radiusCard: 14, shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)', shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)', shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)', textPrimary: '#0f172a', textSecondary: '#64748b', textMuted: '#94a3b8', borderSubtle: '#e2e8f0', divider: '#f1f5f9', surface: '#ffffff', surfaceAlt: '#f8fafc' }; const a = (c, suffix) => `${c}${suffix}`; const tint = (c) => a(c, '08'); const soft = (c) => a(c, '18'); const ring = (c) => a(c, '26'); const edge = (c) => a(c, '55'); const BRAND = '#C01227'; const SoftPaper = (props) => ( ); const AccentAvatar = ({ color, selected, size = 24, children }) => ( {children} ); // Status palette — semantic only (do NOT swap for brand purple). Used by the // per-row status badges and the lifecycle tabs (ALL, Active). // GET /admin/milers returns `availabilitystatus` (confirmed live: Available, // Assigned, On_Pickup, Offline) — not the "active"/"inactive" this used to // assume (there is no `status` field on a miler at all). const STATUS_META = { active: { label: 'Active', color: '#10b981', icon: MdCheckCircle }, inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel }, online: { label: 'Online', color: '#10b981', icon: MdCheckCircle }, offline: { label: 'Offline', color: '#ef4444', icon: MdCancel }, available: { label: 'Available', color: '#10b981', icon: MdCheckCircle }, assigned: { label: 'Assigned', color: '#6366f1', icon: MdTwoWheeler }, on_pickup: { label: 'On Pickup', color: '#14b8a6', icon: MdTwoWheeler }, idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime }, unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2 } }; // Pill-tab definitions for the rider listing tabs. Keeps brand purple for the // "ALL" view and emerald for "Active" so the colour matches the count's meaning. const TAB_META = [ { key: 0, label: 'All Riders', color: BRAND, icon: MdGroups, countKey: 'total' }, { key: 1, label: 'Active', color: BRAND, icon: MdCheckCircle, countKey: 'active' }, { key: 2, label: 'Substitutes', color: BRAND, icon: MdTwoWheeler, countKey: 'substitute' }, { key: 3, label: 'Substitution History', color: BRAND, icon: MdAccessTime, countKey: 'history' } ]; const KPI_META = (summary) => [ { key: 'total', label: 'Total Riders', color: BRAND, icon: MdOutlineGroups, value: summary?.total ?? 0 }, { key: 'active', label: 'Active Riders', color: '#10b981', icon: MdOutlineCheckCircle, value: summary?.active ?? 0 }, { key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 } ]; const Riders = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const loadMoreRef = useRef(); const containerRef = useRef(); const [searchword, setSearchword] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [locaName, setLocoName] = useState('All'); const [appId, setAppId] = useState(0); const [tabvalue, setTabvalue] = useState(0); const roleid = localStorage.getItem('roleid'); const totalCols = roleid == 1 ? 11 : 10; const [logsRow, setLogsRow] = useState(null); const [riderLogsdata] = useState(null); const [historyDate, setHistoryDate] = useState(dayjs()); const [historyStatus, setHistoryStatus] = useState('all'); const [selectedDate, setSelectedDate] = useState(dayjs()); const [editingSub, setEditingSub] = useState(null); const [newSubRider, setNewSubRider] = useState(null); const getHistoryStatusColor = (status) => { const s = (status || '').toLowerCase(); if (s === 'cancelled' || s === 'deleted' || s === 'inactive') return '#ef4444'; // Red if (s === 'scheduled') return '#0ea5e9'; // Blue if (s === 'active' || s === 'completed') return '#10b981'; // Green return '#64748b'; // Gray }; // The rider-substitution feature (/substitutions/*) has no equivalent in the // new Doormile Express API at all — no such resource is documented. These // handlers now no-op with an explanatory toast instead of calling jupiter. const handleDeleteSubstitution = async (row) => { if (window.confirm(`Are you sure you want to delete the substitution for ${row.absent_rider_name}?`)) { OpenToast('Substitutions are not available on the Doormile Express API yet.', 'warning', 2500); } }; const handleUpdateSubstitutionSubmit = async () => { if (!newSubRider) { OpenToast('Please select a substitute rider', 'error', 2000); return; } OpenToast('Substitutions are not available on the Doormile Express API yet.', 'warning', 2500); setEditingSub(null); setNewSubRider(null); }; const [substituteAssignments, setSubstituteAssignments] = useState(() => { try { const saved = localStorage.getItem('rider_substitutions'); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); const handleAssignSubstitute = (activeRiderId, substituteRider) => { const updated = { ...substituteAssignments, [activeRiderId]: substituteRider }; setSubstituteAssignments(updated); localStorage.setItem('rider_substitutions', JSON.stringify(updated)); if (substituteRider) { OpenToast(`Assigned ${substituteRider.username || substituteRider.fullname || 'Rider'} as substitute`, 'success', 2000); } else { OpenToast(`Removed substitute assignment`, 'info', 2000); } }; const handleFinalizeSuccess = () => { setSubstituteAssignments({}); localStorage.removeItem('rider_substitutions'); queryClient.invalidateQueries({ queryKey: ['substitutionsHistory'] }); queryClient.invalidateQueries({ queryKey: ['subRidersLogs'] }); queryClient.invalidateQueries({ queryKey: ['activeSubstitutionsToday'] }); }; const { data: substituteRidersList } = useQuery({ queryKey: ['allRidersForSub', appId], queryFn: async () => { try { const allRiders = (await getMilers()) || []; const allowedIds = [1121, 772, 1116]; return allRiders.filter((rider) => allowedIds.includes(parseInt(rider.userid ?? rider.milerid))); } catch (err) { console.error(err); return []; } } }); // No rider login/checkout audit-log endpoint in the new API. const { data: subRidersLogsData, isLoading: subRidersLogsLoading } = useQuery({ queryKey: ['subRidersLogs', appId, debouncedSearch, selectedDate.format('YYYY-MM-DD')], queryFn: async () => [] }); // The /substitutions resource has no equivalent in the new API. const { data: subHistoryData, isLoading: subHistoryLoading } = useQuery({ queryKey: ['substitutionsHistory', appId, historyDate, historyStatus], queryFn: async () => [] }); const handleChangetab = (i) => { setTabvalue(i); setLogsRow(null); }; // ==============================|| getallridersummary||============================== // const { data: allRidersSummary, isLoading: riderSummarysLoading } = useQuery({ queryKey: ['allriders', appId, tabvalue], queryFn: getallridersummary }); // ==============================|| getRiderLogs (riders)||============================== // // No live GPS/battery/periodic-log endpoint in the new API — the expanded // row still opens, showing the panel's built-in "—" placeholders. const getRiderLogs = async () => { OpenToast('Live rider logs are not available on the Doormile Express API yet.', 'warning', 2500); }; // ==============================|| fetchAllRiders||============================== // // GET /admin/milers isn't paginated (fetchAllRiders always returns the full // list in one shot) — getNextPageParam must not request a "next page" or it // re-fetches and appends the identical full list again, duplicating every // rider in `rows`. const { data: allRidersData, isLoading: allRidersLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ['allriders', appId, debouncedSearch, tabvalue], queryFn: fetchAllRiders, getNextPageParam: () => undefined }); const rows = (allRidersData?.pages.flatMap((page) => page.details || []) || []).filter(Boolean); // GET /admin/milers accepts no search/status params — debouncedSearch and // tabvalue only trigger a refetch (via the queryKey) that returns the exact // same full list every time. Search and the "Active" tab were both silently // no-ops until filtered client-side here. const filteredRows = rows.filter((row) => { // "Active" here means currently online/working (Available/Assigned/ // On_Pickup) — the only real "not working" state is Offline. if (tabvalue === 1 && String(row.availabilitystatus || '').toLowerCase() === 'offline') return false; if (!debouncedSearch) return true; const q = debouncedSearch.toLowerCase(); return [row.displayname, row.phone].some((v) => String(v || '').toLowerCase().includes(q)); }); useEffect(() => { if (!hasNextPage) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { fetchNextPage(); } }, { root: document.querySelector('.MuiTableContainer-root'), rootMargin: '0px', threshold: 1.0 } ); if (loadMoreRef.current) observer.observe(loadMoreRef.current); return () => { if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); }; }, [hasNextPage, fetchNextPage]); const handleScroll = (event) => { const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; if (scrollTop + clientHeight >= scrollHeight - 50) { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } } }; // Per-row status meta — falls back to "unknown" if the rider's state key // isn't in the palette (e.g. brand-new status string from the backend). // Previously joined against a separate rider-status endpoint on every tab // except "All Riders" — that endpoint has no equivalent in the new API and // always returned null, so every rider on every other tab showed "Unknown" // regardless of their real state. Now reads the miler's own // `availabilitystatus` field directly on every tab (confirmed live — there // is no `status` field on a miler at all). const getRowStatusMeta = (row) => { const key = (row?.availabilitystatus || '').toLowerCase(); return STATUS_META[key] || STATUS_META.unknown; }; // PUT /admin/milers/:id/block — body shape isn't documented (Status: Built, // never exercised against a real request), so no fields are sent beyond the // id in the URL. Blocks whichever rider is passed after a confirm prompt. const blockRiderMutation = useMutation({ // /admin/milers/:id routes key off milerprofileid, not userid (confirmed // live — GET /admin/milers/:userid 404s; the miler's own userid field // points at the underlying app-user account, a different resource). mutationFn: (row) => blockMiler(row.milerprofileid), onSuccess: (res) => { if (res.success) { OpenToast('Rider blocked', 'success', 2000); queryClient.invalidateQueries({ queryKey: ['allriders'] }); } else { OpenToast(res.message || 'Failed to block rider', 'error', 2000); } }, onError: (err) => { OpenToast(err.response?.data?.message || err.message || 'Failed to block rider', 'error', 2000); } }); const handleBlockRider = (row) => { if (window.confirm(`Block ${row.displayname || row.authname || 'this rider'}? They will not be able to log in to the miler app.`)) { blockRiderMutation.mutate(row); } }; // PUT /admin/milers/:id/assign-vehicle — body shape isn't documented // (Status: Built, never exercised), so this assumes `vehicleid` following // the codebase's own id convention (GET /admin/vehicles/:id). const [assignVehicleRider, setAssignVehicleRider] = useState(null); const [selectedVehicle, setSelectedVehicle] = useState(null); const { data: vehicles = [] } = useQuery({ queryKey: ['riders-vehicles'], queryFn: getVehicles, enabled: !!assignVehicleRider }); const assignVehicleMutation = useMutation({ mutationFn: () => assignMilerVehicle(assignVehicleRider.milerprofileid, { vehicleid: selectedVehicle?.vehicleid }), onSuccess: (res) => { if (res.success) { OpenToast('Vehicle assigned', 'success', 2000); queryClient.invalidateQueries({ queryKey: ['allriders'] }); setAssignVehicleRider(null); setSelectedVehicle(null); } else { OpenToast(res.message || 'Failed to assign vehicle', 'error', 2000); } }, onError: (err) => { OpenToast(err.response?.data?.message || err.message || 'Failed to assign vehicle', 'error', 2000); } }); return ( <> theme.zIndex.drawer + 1 }} open={ allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading) } > {/* ============================================= || Header | ============================================= */} } placeholder="Select Zone" paperComponent={SoftPaper} sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} /> } /> {/* ============================================= || KPI Cards | ============================================= */} {KPI_META(allRidersSummary).map((item) => { const Icon = item.icon; return ( } color={item.color} loading={riderSummarysLoading} /> ); })} {/* ============================================= || Status Tabs + Search || ============================================= */} {TAB_META.map((t) => { const Icon = t.icon; const active = tabvalue === t.key; const count = t.key === 2 ? subRidersLogsData?.length || 0 : t.key === 3 ? subHistoryData?.length || 0 : allRidersSummary?.[t.countKey] ?? 0; const countLoading = t.key === 2 ? subRidersLogsLoading : t.key === 3 ? subHistoryLoading : riderSummarysLoading; return ( handleChangetab(t.key)} sx={{ display: 'inline-flex', alignItems: 'center', gap: { xs: 0.625, md: 0.875 }, pl: 0.5, pr: { xs: 1, md: 1.25 }, py: 0.5, flexShrink: 0, cursor: 'pointer', borderRadius: '10px', border: `1px solid ${active ? t.color : DT.borderSubtle}`, bgcolor: active ? t.color : DT.surface, color: active ? '#fff' : DT.textSecondary, fontWeight: 600, boxShadow: 'none', transition: 'background-color 0.15s, border-color 0.15s, color 0.15s', '&:hover': { borderColor: active ? t.color : '#cbd5e1', bgcolor: active ? t.color : '#f8fafc' } }} > {t.label} {countLoading ? : count} ); })} {tabvalue === 3 && ( Status: val && setHistoryStatus(val)} aria-label="status selection" size="small" sx={{ '& .MuiToggleButton-root': { borderRadius: '8px', mx: 0.5, border: `1px solid ${DT.borderSubtle}`, color: DT.textSecondary, fontWeight: 600, textTransform: 'capitalize', px: 2, py: 0.5, '&.Mui-selected': { bgcolor: BRAND, color: '#fff', '&:hover': { bgcolor: '#910E1D' } } } }} > All Scheduled newValue && setHistoryDate(newValue)} slotProps={{ textField: { size: 'small', sx: { width: 180, '& .MuiOutlinedInput-root': { borderRadius: '20px', '& fieldset': { borderColor: DT.borderSubtle }, '&:hover fieldset': { borderColor: BRAND }, '&.Mui-focused fieldset': { borderColor: BRAND } } } } }} /> )} {tabvalue === 3 ? ( {subHistoryData?.length === 0 ? ( No substitution history to show Historical substitution logs will be listed here. ) : isMobile ? ( {subHistoryData?.map((row, index) => ( {dayjs(row.sub_date).format('DD MMM YYYY')} {row.status || 'scheduled'} Absent Rider {row.absent_rider_name} #{row.absent_rider_id} Substitute {row.sub_rider_name} #{row.sub_rider_id} {row.reason && ( Reason: {row.reason} )} } /> ))} ) : ( Date Absent Rider Substitute Rider Reason Status Action {subHistoryData?.map((row, index) => ( {dayjs(row.sub_date).format('DD MMM YYYY')} {(row.absent_rider_name || '?').charAt(0).toUpperCase()} {row.absent_rider_name} #{row.absent_rider_id} {(row.sub_rider_name || '?').charAt(0).toUpperCase()} {row.sub_rider_name} #{row.sub_rider_id} {row.reason || '—'} {row.status || 'scheduled'} {(row.status || '').toLowerCase() !== 'cancelled' && ( { setEditingSub(row); setNewSubRider(null); }} > { handleDeleteSubstitution(row); }} > )} ))}
)}
) : tabvalue === 2 ? ( ) : ( {isMobile ? ( /* ===================== MOBILE: card list ===================== */ {allRidersLoading && ( Loading riders… )} {filteredRows?.length === 0 && !allRidersLoading && ( No riders to show {searchword ? 'Try a different keyword.' : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`} )} {filteredRows?.length !== 0 && filteredRows?.map((row, index) => { const statusMeta = getRowStatusMeta(row); const StatusIcon = statusMeta.icon; const expanded = logsRow === row.userid; return ( {String(index + 1).padStart(2, '0')} #{row?.userid} {statusMeta.label} {roleid == 1 && ( { navigate('/doormile/riders/edit', { state: { riderdata: row } }); }} > handleBlockRider(row)} > setAssignVehicleRider(row)} > {tabvalue != 0 && ( { if (row.userid == logsRow) { setLogsRow(null); } else { setLogsRow(row.userid); getRiderLogs(row.userid); } }} > {expanded ? : } )} )} {(row.displayname || row.authname || '?').charAt(0).toUpperCase()} {row.displayname || row.authname || '—'} {row.phone || '—'} } > {row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')} {row.city || ''} {row.defaultvehicletype || '—'} {row.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.starttime}`).format('hh:mm A') : '—'} {row.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.endtime}`).format('hh:mm A') : '—'} {expanded && tabvalue !== 0 && ( Live telemetry — {row.displayname || row.authname || `Rider #${row.userid}`} )} ); })} {filteredRows?.length !== 0 && (
{isFetchingNextPage || hasNextPage ? ( ) : ( No more riders )}
)}
) : ( # ID Rider Address Vehicle Shift Time Fare Fuel Status {roleid == 1 && Action} {allRidersLoading && } {filteredRows?.length === 0 && !allRidersLoading && ( No riders to show {searchword ? 'Try a different keyword.' : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`} )} {filteredRows?.length !== 0 && filteredRows?.map((row, index) => { const statusMeta = getRowStatusMeta(row); const StatusIcon = statusMeta.icon; const expanded = logsRow === row.userid; return ( {String(index + 1).padStart(2, '0')} #{row?.userid} {(row.displayname || row.authname || '?').charAt(0).toUpperCase()} {row.displayname || row.authname || '—'} {row.phone || '—'} {row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')} {row.city || ''} {row.defaultvehicletype || '—'} #{row.shiftid ?? '—'} {row.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.starttime}`).format('hh:mm A') : '—'} {row.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.endtime}`).format('hh:mm A') : '—'} {row.basefare ?? '—'} {row.fuelcharge ?? '—'} {statusMeta.label} {roleid == 1 && ( { navigate('/doormile/riders/edit', { state: { riderdata: row } }); }} > handleBlockRider(row)} > setAssignVehicleRider(row)} > {tabvalue != 0 && ( { if (row.userid == logsRow) { setLogsRow(null); } else { setLogsRow(row.userid); getRiderLogs(row.userid); } }} > {expanded ? : } )} )} {/* ============ Collapsible row — live rider logs ============ */} {expanded && tabvalue !== 0 && ( Live telemetry — {row.displayname || row.authname || `Rider #${row.userid}`} )} ); })} {filteredRows?.length !== 0 && (
{isFetchingNextPage || hasNextPage ? ( ) : ( No more riders )}
)}
)}
)} setEditingSub(null)} maxWidth="xs" fullWidth PaperProps={{ sx: { borderRadius: `${DT.radiusCard / 8}px`, p: 1 } }} > Update Substitution Choose a new substitute rider for {editingSub?.absent_rider_name}. option?.userid === value?.userid} getOptionLabel={(option) => `${option.username || option.fullname || ''} (#${option.userid})`} value={newSubRider} onChange={(event, newValue) => setNewSubRider(newValue)} renderInput={(params) => ( )} /> { setAssignVehicleRider(null); setSelectedVehicle(null); }} maxWidth="xs" fullWidth > Assign Vehicle · {assignVehicleRider?.displayname || assignVehicleRider?.authname} `${option.vehicleno || ''}${option.vehicletype ? ` (${option.vehicletype})` : ''}`} value={selectedVehicle} onChange={(e, value) => setSelectedVehicle(value)} renderInput={(params) => } /> ); }; // Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip // pattern from the pricing page so the telemetry block reads at a glance. const LogChip = ({ color, icon: Icon, label, value }) => ( {label} {value} ); export default Riders;