import { useState, useEffect, useRef, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; var utc = require('dayjs/plugin/utc'); import dayjs from 'dayjs'; dayjs.extend(utc); import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { 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 { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; import { Text } from '@astryxdesign/core/Text'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Badge } from '@astryxdesign/core/Badge'; import { Selector } from '@astryxdesign/core/Selector'; import { DateInput } from '@astryxdesign/core/DateInput'; import { TabList, Tab } from '@astryxdesign/core/TabList'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Button } from '@astryxdesign/core/Button'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import PageHeader from 'components/nearle_components/PageHeader'; import StatCard from 'components/nearle_components/StatCard'; import TableLoader from 'components/nearle_components/TableLoader'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import Loader from 'components/Loader'; import { fetchAllRiders, getallridersummary } from 'pages/api/api'; import { getMilers, blockMiler, assignMilerVehicle, getVehicles, getMilerLogs, getMiler } from 'pages/api/doormileApi'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import { OpenToast } from 'components/third-party/OpenToast'; import RiderSubstitution from './RiderSubstitution'; import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport'; import { DT, tint, edge } from 'themes/dt/tokens'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives'; const BRAND = DT.brand; // 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, badge: 'green' }, inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' }, online: { label: 'Online', color: '#10b981', icon: MdCheckCircle, badge: 'green' }, offline: { label: 'Offline', color: '#ef4444', icon: MdCancel, badge: 'red' }, available: { label: 'Available', color: '#10b981', icon: MdCheckCircle, badge: 'green' }, assigned: { label: 'Assigned', color: '#6366f1', icon: MdTwoWheeler, badge: 'blue' }, on_pickup: { label: 'On Pickup', color: '#14b8a6', icon: MdTwoWheeler, badge: 'teal' }, // Confirmed live: PUT /admin/milers/:id/block sets availabilitystatus to // "Blocked" — a value this map didn't have, so a blocked rider fell // through to "unknown" instead of clearly reading as blocked. blocked: { label: 'Blocked', color: '#7f1d1d', icon: MdBlock, badge: 'red' }, idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime, badge: 'yellow' }, unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2, badge: 'neutral' } }; // 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', icon: MdGroups, countKey: 'total' }, { key: 1, label: 'Active', icon: MdCheckCircle, countKey: 'active' }, { key: 2, label: 'Substitutes', icon: MdTwoWheeler, countKey: 'substitute' }, { key: 3, label: 'Substitution History', icon: MdAccessTime, countKey: 'history' } ]; const HISTORY_STATUS_OPTIONS = [ { value: 'all', label: 'All' }, { value: 'scheduled', label: 'Scheduled' } ]; const KPI_META = (summary) => [ { key: 'total', label: 'Total Riders', color: BRAND, icon: MdOutlineGroups, value: summary?.total ?? 0 }, { key: 'available', label: 'Available', color: '#10b981', icon: MdOutlineCheckCircle, value: summary?.available ?? 0 }, { key: 'onDelivery', label: 'On Delivery', color: '#14b8a6', icon: MdTwoWheeler, value: summary?.onDelivery ?? 0 }, { key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 } ]; // 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} ); const TelemetryPanel = ({ row, riderLogsdata }) => ( Live telemetry — {row.displayname || row.authname || `Rider #${row.userid}`} ); const Riders = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); const isMobile = useIsNarrowViewport(); const loadMoreRef = 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, setRiderLogsdata] = 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); // 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)||============================== // // GET /admin/milers/:id/logs is confirmed live (Status: Done) — id is // milerprofileid, not userid. A log entry's own field names are still // unconfirmed (every rider in the tenant we tested against had an empty // log history), so this falls back to the miler's own last-known position // (currentlatitude/currentlongitude/lastlocationupdatedat/ // availabilitystatus — all confirmed fields) when there's no ping yet, // rather than showing nothing. const getRiderLogs = async (milerprofileid) => { if (!milerprofileid) return; setRiderLogsdata(null); try { const logs = await getMilerLogs(milerprofileid, undefined, undefined, 1); const latest = Array.isArray(logs) ? logs[0] : null; if (latest) { setRiderLogsdata(latest); } else { const miler = await getMiler(milerprofileid); setRiderLogsdata( miler ? { latitude: miler.currentlatitude, longitude: miler.currentlongitude, logdate: miler.lastlocationupdatedat || miler.lastpingat, status: miler.availabilitystatus } : null ); } } catch (err) { OpenToast('Could not load rider logs', 'error', 2000); } }; // ==============================|| 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) — Offline and Blocked (confirmed live enum) both mean not // working. const status = String(row.availabilitystatus || '').toLowerCase(); if (tabvalue === 1 && (status === 'offline' || status === 'blocked')) 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('.dt-table-scroll'), rootMargin: '0px', threshold: 1.0 } ); if (loadMoreRef.current) observer.observe(loadMoreRef.current); return () => { if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); }; }, [hasNextPage, 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); } }); const substituteOptions = (substituteRidersList || []).map((r) => ({ value: String(r.userid), label: `${r.username || r.fullname || ''} (#${r.userid})` })); const vehicleOptions = (vehicles || []).map((v) => ({ value: String(v.vehicleid), label: `${v.vehicleno || ''}${v.vehicletype ? ` (${v.vehicletype})` : ''}` })); const RowActions = ({ row, expanded }) => ( } onClick={() => navigate('/doormile/riders/edit', { state: { riderdata: row } })} /> } onClick={() => handleBlockRider(row)} /> } onClick={() => setAssignVehicleRider(row)} /> {tabvalue != 0 && ( : } onClick={() => { if (row.milerprofileid == logsRow) { setLogsRow(null); } else { setLogsRow(row.milerprofileid); getRiderLogs(row.milerprofileid); } }} /> )} ); return ( <> {(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && ( )} } /> {KPI_META(allRidersSummary).map((item) => { const Icon = item.icon; return ( } color={item.color} loading={riderSummarysLoading} /> ); })} {tabvalue === 3 && ( Status: {HISTORY_STATUS_OPTIONS.map((o) => ( ))} )} {tabvalue === 3 && ( v && setHistoryDate(dayjs(v))} /> )} } tabs={ handleChangetab(Number(v))} size="sm"> {TAB_META.map((t) => { const Icon = t.icon; const count = t.key === 2 ? subRidersLogsData?.length || 0 : t.key === 3 ? subHistoryData?.length || 0 : allRidersSummary?.[t.countKey] ?? 0; return ( } endContent={} /> ); })} } > {tabvalue === 3 ? ( <> {subHistoryData?.length === 0 ? ( } /> ) : isMobile ? ( {subHistoryData?.map((row, index) => ( {dayjs(row.sub_date).format('DD MMM YYYY')} 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 || '').toLowerCase() !== 'cancelled' && ( } onClick={() => { setEditingSub(row); setNewSubRider(null); }} /> } onClick={() => handleDeleteSubstitution(row)} /> )} ))}
)} ) : tabvalue === 2 ? ( ) : ( <> {isMobile ? ( {allRidersLoading && ( Loading riders… )} {filteredRows?.length === 0 && !allRidersLoading && ( } /> )} {filteredRows?.length !== 0 && filteredRows?.map((row, index) => { const statusMeta = getRowStatusMeta(row); const expanded = logsRow === row.milerprofileid; return ( {String(index + 1).padStart(2, '0')}
#{row?.userid}
} label={statusMeta.label} />
{roleid == 1 && }
{row.displayname || row.authname || '—'} {row.phone || '—'} } > {row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')} {row.city || ''} {row.defaultvehicletype || '—'} {expanded && tabvalue !== 0 && (
)}
); })} {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 && ( } /> )} {filteredRows?.length !== 0 && filteredRows?.map((row, index) => { const statusMeta = getRowStatusMeta(row); const expanded = logsRow === row.milerprofileid; return ( {String(index + 1).padStart(2, '0')}
#{row?.userid}
{row.displayname || row.authname || '—'} {row.phone || '—'} {row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')} {row.city || ''} {row.defaultvehicletype || '—'} #{row.shiftid ?? '—'} {row.basefare ?? '—'} {row.fuelcharge ?? '—'} } label={statusMeta.label} /> {roleid == 1 && ( )}
{expanded && tabvalue !== 0 && ( )}
); })} {filteredRows?.length !== 0 && (
{isFetchingNextPage || hasNextPage ? ( ) : ( No more riders )}
)}
)} )}
!next && setEditingSub(null)} width={420} purpose="form"> !next && setEditingSub(null)} />} content={ Choose a new substitute rider for {editingSub?.absent_rider_name}. setNewSubRider((substituteRidersList || []).find((r) => String(r.userid) === v) || null)} placeholder="Select Substitute Rider" hasSearch /> } footer={ { if (!next) { setAssignVehicleRider(null); setSelectedVehicle(null); } }} width={420} purpose="form" > { if (!next) { setAssignVehicleRider(null); setSelectedVehicle(null); } }} /> } content={ setSelectedVehicle((vehicles || []).find((x) => String(x.vehicleid) === v) || null)} placeholder="Choose vehicle" hasSearch /> } footer={ ); }; export default Riders;