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>
1423 lines
61 KiB
JavaScript
1423 lines
61 KiB
JavaScript
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;
|