Files
Doormilexpress_console/src/pages/nearle/riders/riders.js
dharaneesh-r 414ba6dbf2 Fix assistant assign flow, cache staleness, and sweep KPI gloss lines
Assistant — assign:
- Order step is a dropdown, not a typed booking number. Exact bookingid /
  bookingno matches now win across the whole list before any substring
  fallback; row order previously decided the winner, so a short numeric id
  could substring-match a different booking than the one on screen.
- Rider step resolves by id or name, so answering by typing no longer
  leaves __rider undefined and throws inside executeAssign.
- notified now reflects whether the push actually went out. It was
  Boolean(rider.milerprofileid) — "has an id, assume they were told" — so a
  notify returning 400 still reported "The rider has been notified."
- Batch assign removed. /hub/bookings/batch-assign is a hub-console
  endpoint; an admin JWT is refused (403 on an empty body, so the gate is
  on identity). It read as the fast path and could never run.

Assistant — repeat runs:
- Bulk create response is read at data.results, the shape the live endpoint
  returns. Only the two flatter shapes were checked, so every bookingid was
  discarded while the count came out right by accident.
- Repeat carries each order's previous rider and re-assigns on create, one
  admin call per order, one push per rider. Confirm cards say so.
- Message ids are clock-seeded and healed on load; the counter restarted at
  1 each session and overwrote itself, giving duplicate React keys.
- Successful writes invalidate the query cache, so the page behind the panel
  stops serving rows cached before the write.

Date range picker:
- Fields seed from the range already in force instead of opening blank on a
  filtered table.
- Start field takes max={end}; only the end had a bound, so an inverted
  range could be emitted and filtered to nothing.

UI:
- Deliveries filters moved outside the table card.
- Side nav keeps one hairline; the border and box-shadow stacked into a
  heavy edge.
- KPI gloss lines removed across orders, riders, tenants, customers, hubs,
  vehicles, exceptions, app users, orders summary/details, riders summary
  and profitability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:51:32 +05:30

1423 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// 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 }) => (
<HStack
gap={1.5}
vAlign="center"
padding={2}
style={{ borderRadius: 5, background: '#fff', border: `1px solid ${edge(color)}`, minWidth: 0 }}
>
<AccentAvatar color={color} size={28}>
<Icon size={14} />
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.4, textTransform: 'uppercase', fontSize: 9.5 }}>
{label}
</Text>
<Text weight="bold" maxLines={1}>
{value}
</Text>
</VStack>
</HStack>
);
const TelemetryPanel = ({ row, riderLogsdata }) => (
<Card padding={3} elevation="none" style={{ background: '#ffffff', border: `1px solid ${edge(BRAND)}` }}>
<HStack gap={1.5} vAlign="center" style={{ marginBottom: 10 }}>
<AccentAvatar color={BRAND} size={26}>
<MdGpsFixed size={14} />
</AccentAvatar>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Live telemetry — {row.displayname || row.authname || `Rider #${row.userid}`}
</Text>
</HStack>
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={1.5}>
<LogChip
color="#ef4444"
icon={MdLocationOn}
label="Location"
value={riderLogsdata?.latitude ? `${riderLogsdata.latitude}, ${riderLogsdata.longitude}` : '—'}
/>
<LogChip color="#10b981" icon={MdBatteryStd} label="Battery" value={riderLogsdata?.battery ? `${riderLogsdata.battery}%` : 'N/A'} />
<LogChip
color={riderLogsdata?.is_charging ? '#10b981' : '#94a3b8'}
icon={MdPowerSettingsNew}
label="Charging"
value={riderLogsdata?.is_charging ? 'Charging' : 'Not Charging'}
/>
<LogChip
color="#0ea5e9"
icon={MdSpeed}
label="Speed"
value={riderLogsdata?.speed !== undefined ? `${riderLogsdata.speed} km/h` : '—'}
/>
<LogChip
color="#8b5cf6"
icon={MdGpsFixed}
label="Accuracy"
value={riderLogsdata?.accuracy !== undefined ? `${riderLogsdata.accuracy} m` : '—'}
/>
<LogChip color="#f59e0b" icon={MdAccessTime} label="Log time" value={riderLogsdata?.logdate || '—'} />
<LogChip color={BRAND} icon={MdInventory2} label="Active order" value={riderLogsdata?.orderid || 'N/A'} />
<LogChip
color={riderLogsdata?.status === 'idle' ? '#f59e0b' : '#10b981'}
icon={MdCheckCircle}
label="Status"
value={riderLogsdata?.status || 'unknown'}
/>
</Grid>
</Card>
);
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 <resource>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 }) => (
<HStack gap={0.75} justify="end">
<IconButton
label="Edit rider"
tooltip="Edit rider"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => navigate('/doormile/riders/edit', { state: { riderdata: row } })}
/>
<IconButton
label="Block rider"
tooltip="Block rider"
size="sm"
variant="destructive"
icon={<MdBlock size={14} />}
onClick={() => handleBlockRider(row)}
/>
<IconButton
label="Assign vehicle"
tooltip="Assign vehicle"
size="sm"
variant="ghost"
icon={<MdDirectionsCar size={14} />}
onClick={() => setAssignVehicleRider(row)}
/>
{tabvalue != 0 && (
<IconButton
label={expanded ? 'Hide logs' : 'View live logs'}
tooltip={expanded ? 'Hide logs' : 'View live logs'}
size="sm"
variant={expanded ? 'primary' : 'ghost'}
icon={expanded ? <MdKeyboardArrowUp size={14} /> : <MdKeyboardArrowDown size={14} />}
onClick={() => {
if (row.milerprofileid == logsRow) {
setLogsRow(null);
} else {
setLogsRow(row.milerprofileid);
getRiderLogs(row.milerprofileid);
}
}}
/>
)}
</HStack>
);
// 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: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'id',
accessorKey: 'userid',
size: 90,
minSize: 70,
meta: { label: 'ID' },
header: () => <SectionLabel>ID</SectionLabel>,
cell: ({ getValue }) => (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
}}
>
#{getValue()}
</div>
)
},
{
id: 'rider',
accessorFn: (row) => row.displayname || row.authname || '—',
size: 220,
minSize: 180,
meta: { label: 'Rider' },
header: () => <SectionLabel>Rider</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={1.5} vAlign="center">
<AccentAvatar color="#8b5cf6" size={28}>
<MdDirectionsBike size={14} />
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{row.original.phone || '—'}
</Text>
</VStack>
</HStack>
)
},
{
id: 'address',
accessorFn: (row) => row.suburb || row.address || '',
size: 220,
minSize: 160,
meta: { label: 'Address' },
header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ row }) => (
<Tooltip content={row.original.address || ''}>
<VStack gap={0.5} padding={0}>
<Text weight="medium" maxLines={1} style={{ fontSize: 12 }}>
{row.original.suburb || row.original.address || '—'}
</Text>
{row.original.city && (
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{row.original.city}
</Text>
)}
</VStack>
</Tooltip>
)
},
{
id: 'vehicle',
accessorKey: 'defaultvehicletype',
size: 140,
minSize: 110,
meta: { label: 'Vehicle' },
header: () => <SectionLabel>Vehicle</SectionLabel>,
cell: ({ getValue }) => (
<HStack gap={1} vAlign="center">
<AccentAvatar color="#0ea5e9" size={22}>
<MdTwoWheeler size={12} />
</AccentAvatar>
<Text type="supporting" weight="bold">
{getValue() || '—'}
</Text>
</HStack>
)
},
{
id: 'shift',
accessorKey: 'shiftid',
size: 90,
minSize: 70,
meta: { label: 'Shift' },
header: () => <SectionLabel>Shift</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" weight="bold" color="secondary">
#{getValue() ?? '—'}
</Text>
)
},
{
id: 'time',
size: 130,
minSize: 110,
enableSorting: false,
meta: { label: 'Time', align: 'center' },
header: () => <SectionLabel>Time</SectionLabel>,
cell: ({ row }) => (
<VStack gap={0.5} padding={0} vAlign="center">
<Badge
variant="green"
label={row.original.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.original.starttime}`).format('hh:mm A') : '—'}
/>
<Badge
variant="red"
label={row.original.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.original.endtime}`).format('hh:mm A') : '—'}
/>
</VStack>
)
},
{
id: 'fare',
accessorKey: 'basefare',
size: 100,
minSize: 80,
meta: { label: 'Fare' },
header: () => <SectionLabel>Fare</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" weight="bold">
{getValue() ?? '—'}
</Text>
)
},
{
id: 'fuel',
accessorKey: 'fuelcharge',
size: 100,
minSize: 80,
meta: { label: 'Fuel' },
header: () => <SectionLabel>Fuel</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" weight="bold">
{getValue() ?? '—'}
</Text>
)
},
{
id: 'status',
accessorKey: 'availabilitystatus',
size: 130,
minSize: 110,
meta: { label: 'Status', align: 'center' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ row }) => {
const meta = getRowStatusMeta(row.original);
return <Badge variant={meta.badge} icon={<meta.icon size={12} />} label={meta.label} />;
}
}
];
if (roleid == 1) {
base.push({
id: 'action',
size: 150,
minSize: 120,
enableSorting: false,
enableResizing: false,
meta: { label: 'Action' },
header: () => <SectionLabel>Action</SectionLabel>,
cell: ({ row }) => <RowActions row={row.original} expanded={logsRow === row.original.milerprofileid} />
});
}
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)) && (
<Loader />
)}
<PageShell>
<PageHeader
title="Riders"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} placeholder="Select Zone" />}
/>
<KpiGrid>
{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 (
<StatCard
key={item.key}
title={item.label}
value={item.value ?? 0}
meta={share}
icon={<Icon size={20} />}
color={item.color}
loading={riderSummarysLoading}
/>
);
})}
</KpiGrid>
<DataCard
// History-only controls (status filter + date) get their own row —
// ONLY on that tab, so the other three tabs never show an empty
// toolbar strip above the tabs+search row.
toolbar={
tabvalue === 3 ? (
<FilterToolbar max={2}>
<HStack gap={2} vAlign="center">
<Text weight="bold" color="secondary">
Status:
</Text>
<SegmentedControl label="History status" value={historyStatus} onChange={setHistoryStatus} size="sm">
{HISTORY_STATUS_OPTIONS.map((o) => (
<SegmentedControlItem key={o.value} value={o.value} label={o.label} />
))}
</SegmentedControl>
</HStack>
<DateInput
label="Select Date"
isLabelHidden
size="sm"
value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setHistoryDate(dayjs(v))}
/>
</FilterToolbar>
) : 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={
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
<TabList value={String(tabvalue)} onChange={(v) => 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 (
<Tab
key={t.key}
value={String(t.key)}
label={t.label}
icon={<Icon size={15} />}
endContent={<Badge variant="neutral" label={count} />}
/>
);
})}
</TabList>
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search riders (ctrl+k)"
/>
</VStack>
</HStack>
}
>
{tabvalue === 3 ? (
<>
{subHistoryData?.length === 0 ? (
<EmptyState
title="No substitution history to show"
description="Historical substitution logs will be listed here."
icon={<MdAccessTime size={28} />}
/>
) : isMobile ? (
<MobileCardList scroll>
{subHistoryData?.map((row, index) => (
<MobileCard
key={row.id ?? index}
accent="#f59e0b"
header={
<VStack gap={1.5} padding={0}>
<HStack justify="between" vAlign="center">
<Text type="supporting" weight="bold" color="secondary">
{dayjs(row.sub_date).format('DD MMM YYYY')}
</Text>
<Badge variant="neutral" label={row.status || 'scheduled'} />
</HStack>
<HStack justify="between">
<VStack gap={0.5} padding={0} style={{ width: '45%' }}>
<Text type="supporting" weight="bold" color="secondary">
Absent Rider
</Text>
<Text weight="bold" maxLines={1}>
{row.absent_rider_name}
</Text>
<Text type="supporting" color="secondary">
#{row.absent_rider_id}
</Text>
</VStack>
<MdArrowForward size={16} color={DT.textSecondary} />
<VStack gap={0.5} padding={0} style={{ width: '45%', alignItems: 'flex-end', textAlign: 'right' }}>
<Text type="supporting" weight="bold" color="secondary">
Substitute
</Text>
<Text weight="bold" maxLines={1}>
{row.sub_rider_name}
</Text>
<Text type="supporting" color="secondary">
#{row.sub_rider_id}
</Text>
</VStack>
</HStack>
{row.reason && (
<Text type="supporting" color="secondary" style={{ paddingTop: 4, borderTop: `1px solid ${DT.divider}` }}>
Reason: <strong>{row.reason}</strong>
</Text>
)}
</VStack>
}
/>
))}
</MobileCardList>
) : (
<TableScroll minWidth={800}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>Date</TableHeaderCell>
<TableHeaderCell>Absent Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell>
<TableHeaderCell>Substitute Rider</TableHeaderCell>
<TableHeaderCell>Reason</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{subHistoryData?.map((row, index) => (
<TableRow key={row.id ?? index}>
<TableCell>
<Text weight="semibold">{dayjs(row.sub_date).format('DD MMM YYYY')}</Text>
</TableCell>
<TableCell>
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={BRAND} selected size={32}>
<Text weight="bold">{(row.absent_rider_name || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold">{row.absent_rider_name}</Text>
<Text type="supporting" color="secondary">
#{row.absent_rider_id}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<MdArrowForward size={18} color={DT.textSecondary} />
</TableCell>
<TableCell>
<HStack gap={1.5} vAlign="center">
<AccentAvatar color="#10b981" selected size={32}>
<Text weight="bold">{(row.sub_rider_name || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold">{row.sub_rider_name}</Text>
<Text type="supporting" color="secondary">
#{row.sub_rider_id}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell>
<Text>{row.reason || '—'}</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant="neutral" label={row.status || 'scheduled'} />
</TableCell>
<TableCell>
{(row.status || '').toLowerCase() !== 'cancelled' && (
<HStack gap={0.75} justify="end">
<IconButton
label="Update substitution"
tooltip="Update substitution"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => {
setEditingSub(row);
setNewSubRider(null);
}}
/>
<IconButton
label="Delete substitution"
tooltip="Delete substitution"
size="sm"
variant="destructive"
icon={<MdDelete size={14} />}
onClick={() => handleDeleteSubstitution(row)}
/>
</HStack>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableScroll>
)}
</>
) : tabvalue === 2 ? (
<RiderSubstitution
bare
appId={appId}
rows={subRidersLogsData || []}
allRidersList={rows}
substituteRidersList={substituteRidersList}
substituteAssignments={substituteAssignments}
handleAssignSubstitute={handleAssignSubstitute}
onFinalizeSuccess={handleFinalizeSuccess}
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
roleid={roleid}
/>
) : (
<>
{isMobile ? (
<MobileCardList scroll>
{allRidersLoading && (
<VStack vAlign="center" padding={4}>
<LoaderWithImage />
<Text type="supporting" color="secondary" style={{ marginTop: 8 }}>
Loading riders…
</Text>
</VStack>
)}
{filteredRows?.length === 0 && !allRidersLoading && (
<EmptyState
title="No riders to show"
description={searchword ? 'Try a different keyword.' : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`}
icon={<MdTwoWheeler size={28} />}
/>
)}
{/* 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 (
<MobileCard
key={tableRow.id ?? index}
accent={statusMeta.color}
selected={expanded}
header={
<VStack gap={1.5} padding={0}>
<HStack justify="between" vAlign="center" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Text>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11
}}
>
#{row?.userid}
</div>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={11} />} label={statusMeta.label} />
</HStack>
{roleid == 1 && <RowActions row={row} expanded={expanded} />}
</HStack>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{row.displayname || row.authname || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.phone || '—'}
</Text>
</VStack>
</VStack>
}
>
<MobileFieldGrid>
<MobileField label="Address" full>
<Text weight="semibold" maxLines={1}>
{row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.city || ''}
</Text>
</MobileField>
<MobileField label="Vehicle">
<HStack gap={1} vAlign="center">
<AccentAvatar color="#0ea5e9" size={22}>
<MdTwoWheeler size={12} />
</AccentAvatar>
<Text type="supporting" weight="bold">
{row.defaultvehicletype || '—'}
</Text>
</HStack>
</MobileField>
<MobileField label="Shift" value={`#${row.shiftid ?? '—'}`} />
<MobileField label="Time">
<VStack gap={0.5} padding={0}>
<Badge
variant="green"
label={row.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.starttime}`).format('hh:mm A') : '—'}
/>
<Badge
variant="red"
label={row.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.endtime}`).format('hh:mm A') : '—'}
/>
</VStack>
</MobileField>
<MobileField label="Fare" value={row.basefare ?? '—'} />
<MobileField label="Fuel" value={row.fuelcharge ?? '—'} />
</MobileFieldGrid>
{expanded && tabvalue !== 0 && (
<div style={{ marginTop: 'var(--spacing-6)' }}>
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
</div>
)}
</MobileCard>
);
})}
</MobileCardList>
) : (
<TableScroll minWidth={table.getTotalSize()}>
{/* 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
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Riders"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} isHeaderRow>
{headerGroup.headers.map((header) => {
const align = header.column.columnDef.meta?.align;
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
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' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{allRidersLoading && <TableLoader rows={6} columns={colCount} />}
{filteredRows?.length === 0 && !allRidersLoading && (
<TableRow>
<TableCell colSpan={colCount}>
<EmptyState
title="No riders to show"
description={
searchword ? 'Try a different keyword.' : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`
}
icon={<MdTwoWheeler size={28} />}
/>
</TableCell>
</TableRow>
)}
{filteredRows?.length !== 0 &&
table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const expanded = logsRow === row.milerprofileid;
return (
<Fragment key={tableRow.id}>
<TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}>
{/* getAllCells (core), not getVisibleCells —
that one is gated behind
columnVisibilityFeature, which this table
doesn't register. */}
{tableRow.getAllCells().map((cell) => (
<TableCell
key={cell.id}
style={{
textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{expanded && tabvalue !== 0 && (
<TableRow>
<TableCell colSpan={colCount} style={{ padding: 12 }}>
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</TableScroll>
)}
{/* 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 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => 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"
/>
</HStack>
</>
)}
</>
)}
</DataCard>
</PageShell>
<Dialog isOpen={Boolean(editingSub)} onOpenChange={(next) => !next && setEditingSub(null)} width={420} purpose="form">
<Layout
header={<DialogHeader title="Update Substitution" onOpenChange={(next) => !next && setEditingSub(null)} />}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Text color="secondary">
Choose a new substitute rider for <strong>{editingSub?.absent_rider_name}</strong>.
</Text>
<Selector
label="Substitute Rider"
isLabelHidden
options={substituteOptions}
value={newSubRider ? String(newSubRider.userid) : ''}
onChange={(v) => setNewSubRider((substituteRidersList || []).find((r) => String(r.userid) === v) || null)}
placeholder="Select Substitute Rider"
hasSearch
/>
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setEditingSub(null)} />
<Button label="Save Changes" variant="primary" isDisabled={!newSubRider} onClick={handleUpdateSubstitutionSubmit} />
</HStack>
</LayoutFooter>
}
/>
</Dialog>
<Dialog
isOpen={!!assignVehicleRider}
onOpenChange={(next) => {
if (!next) {
setAssignVehicleRider(null);
setSelectedVehicle(null);
}
}}
width={420}
purpose="form"
>
<Layout
header={
<DialogHeader
title={`Assign Vehicle · ${assignVehicleRider?.displayname || assignVehicleRider?.authname || ''}`}
onOpenChange={(next) => {
if (!next) {
setAssignVehicleRider(null);
setSelectedVehicle(null);
}
}}
/>
}
content={
<LayoutContent padding={4}>
<Selector
label="Vehicle"
isLabelHidden
options={vehicleOptions}
value={selectedVehicle ? String(selectedVehicle.vehicleid) : ''}
onChange={(v) => setSelectedVehicle((vehicles || []).find((x) => String(x.vehicleid) === v) || null)}
placeholder="Choose vehicle"
hasSearch
/>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button
label="Cancel"
variant="secondary"
onClick={() => {
setAssignVehicleRider(null);
setSelectedVehicle(null);
}}
/>
<Button label="Assign" variant="primary" isDisabled={!selectedVehicle} onClick={() => assignVehicleMutation.mutate()} />
</HStack>
</LayoutFooter>
}
/>
</Dialog>
</>
);
};
export default Riders;