import { useState, useEffect, useMemo, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
var utc = require('dayjs/plugin/utc');
import dayjs from 'dayjs';
dayjs.extend(utc);
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
// TanStack Table v9 — same engine orders.js uses, so sorting/resizing/
// pagination behave identically across the two pages.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import {
MdCheckCircle,
MdCancel,
MdGroups,
MdOutlineGroups,
MdOutlineCheckCircle,
MdOutlineCancel,
MdEdit,
MdKeyboardArrowDown,
MdKeyboardArrowUp,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore,
MdLocationOn,
MdBatteryStd,
MdPowerSettingsNew,
MdSpeed,
MdGpsFixed,
MdAccessTime,
MdInventory2,
MdTwoWheeler,
MdArrowForward,
MdDelete,
MdBlock,
MdDirectionsBike,
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 { Center } from '@astryxdesign/core/Center';
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 { Divider } from '@astryxdesign/core/Divider';
import { Button } from '@astryxdesign/core/Button';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
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 }
];
// TanStack Table v9 registers features explicitly, same pattern orders.js
// uses — only sorting/resizing/pagination ship here (no row-selection or
// column-visibility, since this table has no bulk-select flow).
const RIDERS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const RIDERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's table uses.
const SectionLabel = ({ children }) => (
{children}
);
// 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 [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 [logsRow, setLogsRow] = useState(null);
// Table sorting/resizing/pagination state — mirrors orders.js's slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
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 (`{ details, nextPage: undefined }`). Previously
// wrapped in useInfiniteQuery with a getNextPageParam that always returned
// undefined, so "loading more" never actually fetched a second page — a
// plain bulk fetch, same shape orders.js uses for bookings, with the table
// below paginating the already-complete list client-side.
const { data: allRidersResult, isLoading: allRidersLoading } = useQuery({
queryKey: ['allriders', appId, debouncedSearch, tabvalue],
queryFn: fetchAllRiders
});
const rows = useMemo(() => (allRidersResult?.details || []).filter(Boolean), [allRidersResult]);
// 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.
//
// Memoized (unlike the pre-TanStack version of this file) because the table
// reads this as `data`: TanStack memoizes its core row model on the `data`
// REFERENCE and auto-resets `pageIndex` to 0 whenever that reference
// changes. An unmemoized `.filter()` returns a new array every render —
// including the very render a page-2/3/4 click causes — so pagination
// looked broken (every click snapped straight back to page 1). orders.js's
// `filteredRows` was already memoized for the same reason.
const filteredRows = useMemo(
() =>
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)
);
}),
[rows, tabvalue, debouncedSearch]
);
// A tab switch or search re-scopes the list, so page 1 is the only page
// that still means anything — same reset orders.js does on its own table.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [tabvalue, debouncedSearch]);
// 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);
}
}}
/>
)}
);
// The column model — everything a column IS (id, accessor, header, cell,
// width, sortability) declared once here instead of split across a
// hardcoded header row and a hand-written body row, same shape as
// orders.js's `columns`. The Action column is appended conditionally
// because it only exists for roleid 1, not hidden via a visibility slice.
const columns = useMemo(() => {
const base = [
{
id: 'serial',
size: 40,
minSize: 32,
maxSize: 48,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => # ,
cell: ({ table: t, row }) => (
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
)
},
{
id: 'id',
accessorKey: 'userid',
size: 90,
minSize: 70,
meta: { label: 'ID' },
header: () => ID ,
cell: ({ getValue }) => (
#{getValue()}
)
},
{
id: 'rider',
accessorFn: (row) => row.displayname || row.authname || '—',
size: 220,
minSize: 180,
meta: { label: 'Rider' },
header: () => Rider ,
cell: ({ getValue, row }) => (
{getValue()}
{row.original.phone || '—'}
)
},
{
id: 'address',
accessorFn: (row) => row.suburb || row.address || '',
size: 220,
minSize: 160,
meta: { label: 'Address' },
header: () => Address ,
cell: ({ row }) => (
{row.original.suburb || row.original.address || '—'}
{row.original.city && (
{row.original.city}
)}
)
},
{
id: 'vehicle',
accessorKey: 'defaultvehicletype',
size: 140,
minSize: 110,
meta: { label: 'Vehicle' },
header: () => Vehicle ,
cell: ({ getValue }) => (
{getValue() || '—'}
)
},
{
id: 'shift',
accessorKey: 'shiftid',
size: 90,
minSize: 70,
meta: { label: 'Shift' },
header: () => Shift ,
cell: ({ getValue }) => (
#{getValue() ?? '—'}
)
},
{
id: 'time',
size: 130,
minSize: 110,
enableSorting: false,
meta: { label: 'Time', align: 'center' },
header: () => Time ,
cell: ({ row }) => (
)
},
{
id: 'fare',
accessorKey: 'basefare',
size: 100,
minSize: 80,
meta: { label: 'Fare' },
header: () => Fare ,
cell: ({ getValue }) => (
{getValue() ?? '—'}
)
},
{
id: 'fuel',
accessorKey: 'fuelcharge',
size: 100,
minSize: 80,
meta: { label: 'Fuel' },
header: () => Fuel ,
cell: ({ getValue }) => (
{getValue() ?? '—'}
)
},
{
id: 'status',
accessorKey: 'availabilitystatus',
size: 130,
minSize: 110,
meta: { label: 'Status', align: 'center' },
header: () => Status ,
cell: ({ row }) => {
const meta = getRowStatusMeta(row.original);
return } label={meta.label} />;
}
}
];
if (roleid == 1) {
base.push({
id: 'action',
size: 150,
minSize: 120,
enableSorting: false,
enableResizing: false,
meta: { label: 'Action' },
header: () => Action ,
cell: ({ row }) =>
});
}
return base;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roleid, logsRow, pagination.pageIndex, pagination.pageSize]);
const table = useTable({
features: RIDERS_TABLE_FEATURES,
// The full filtered set — the table paginates it, same pattern
// orders.js uses for its (also client-filtered) bookings list.
data: filteredRows,
columns,
getRowId: (row, index) => String(row.userid ?? row.milerprofileid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered — the Action column only exists for roleid 1. Read off
// `columns` directly (not `table.getVisibleLeafColumns()`): that method
// only exists when columnVisibilityFeature is registered, which this
// table doesn't need (unlike orders.js — there is no column-hide menu here).
const colCount = columns.length;
return (
<>
{(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && (
)}
}
/>
{KPI_META(allRidersSummary).map((item) => {
const Icon = item.icon;
const total = allRidersSummary?.total ?? 0;
const share =
item.key === 'total'
? `${item.value} riders`
: total > 0
? `${Math.round((item.value / total) * 100)}% of ${total}`
: null;
// Gloss lines dropped, matching the Orders KPI strip: "Active"
// does not need "on duty & free" underneath it for people who run
// this page all day, and the second line competes with the number
// the card exists to show.
return (
}
color={item.color}
loading={riderSummarysLoading}
/>
);
})}
Status:
{HISTORY_STATUS_OPTIONS.map((o) => (
))}
v && setHistoryDate(dayjs(v))}
/>
) : undefined
}
// Tabs and search share ONE row (same pattern orders.js uses) —
// previously the search box lived in its own toolbar row above the
// tab strip, which read as two stacked rows instead of one control
// strip.
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 && (
}
/>
)}
{/* Same paginated + sorted row model the desktop table
reads, so a mobile viewer and a desktop viewer never see
a different page of riders for the same filters. */}
{filteredRows?.length !== 0 &&
table.getRowModel().rows.map((tableRow, index) => {
const row = tableRow.original;
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 && (
)}
);
})}
) : (
{/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's table.
Header and body are both rendered FROM the column
model, so they cannot drift apart. */}
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => {
const align = header.column.columnDef.meta?.align;
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
) : sorted === 'desc' ? (
) : (
))}
{header.column.getCanResize() && (
)}
);
})}
))}
{allRidersLoading && }
{filteredRows?.length === 0 && !allRidersLoading && (
}
/>
)}
{filteredRows?.length !== 0 &&
table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const expanded = logsRow === row.milerprofileid;
return (
{/* getAllCells (core), not getVisibleCells —
that one is gated behind
columnVisibilityFeature, which this table
doesn't register. */}
{tableRow.getAllCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
{expanded && tabvalue !== 0 && (
)}
);
})}
)}
{/* Pagination replaces the old scroll-to-reveal sentinel — the
API returns the full rider list in one shot, so this was
always paginating an in-memory array, just without any UI
for it. Shared between mobile and desktop since both read
the same `table` row model above. */}
{filteredRows.length > 0 && (
<>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
table.setPageIndex(nextPage - 1)}
totalItems={filteredRows.length}
pageSize={pagination.pageSize}
pageSizeOptions={RIDERS_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Riders pagination"
/>
>
)}
>
)}
!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={
setEditingSub(null)} />
}
/>
{
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={
{
setAssignVehicleRider(null);
setSelectedVehicle(null);
}}
/>
assignVehicleMutation.mutate()} />
}
/>
>
);
};
export default Riders;