Files
Doormilexpress_console/src/pages/nearle/riders/riders.js
2026-08-17 17:14:51 +05:30

1137 lines
50 KiB
JavaScript

import { useState, useEffect, useRef, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
var utc = require('dayjs/plugin/utc');
import dayjs from 'dayjs';
dayjs.extend(utc);
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
MdCheckCircle,
MdCancel,
MdGroups,
MdOutlineGroups,
MdOutlineCheckCircle,
MdOutlineCancel,
MdEdit,
MdKeyboardArrowDown,
MdKeyboardArrowUp,
MdLocationOn,
MdBatteryStd,
MdPowerSettingsNew,
MdSpeed,
MdGpsFixed,
MdAccessTime,
MdInventory2,
MdTwoWheeler,
MdArrowForward,
MdDelete,
MdBlock,
MdDirectionsCar
} from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Badge } from '@astryxdesign/core/Badge';
import { Selector } from '@astryxdesign/core/Selector';
import { DateInput } from '@astryxdesign/core/DateInput';
import { TabList, Tab } from '@astryxdesign/core/TabList';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Button } from '@astryxdesign/core/Button';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import Loader from 'components/Loader';
import { fetchAllRiders, getallridersummary } from 'pages/api/api';
import { getMilers, blockMiler, assignMilerVehicle, getVehicles, getMilerLogs, getMiler } from 'pages/api/doormileApi';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import { OpenToast } from 'components/third-party/OpenToast';
import RiderSubstitution from './RiderSubstitution';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT, tint, edge } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
// Status palette — semantic only (do NOT swap for brand purple). Used by the
// per-row status badges and the lifecycle tabs (ALL, Active).
// GET /admin/milers returns `availabilitystatus` (confirmed live: Available,
// Assigned, On_Pickup, Offline) — not the "active"/"inactive" this used to
// assume (there is no `status` field on a miler at all).
const STATUS_META = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
online: { label: 'Online', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
offline: { label: 'Offline', color: '#ef4444', icon: MdCancel, badge: 'red' },
available: { label: 'Available', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
assigned: { label: 'Assigned', color: '#6366f1', icon: MdTwoWheeler, badge: 'blue' },
on_pickup: { label: 'On Pickup', color: '#14b8a6', icon: MdTwoWheeler, badge: 'teal' },
// Confirmed live: PUT /admin/milers/:id/block sets availabilitystatus to
// "Blocked" — a value this map didn't have, so a blocked rider fell
// through to "unknown" instead of clearly reading as blocked.
blocked: { label: 'Blocked', color: '#7f1d1d', icon: MdBlock, badge: 'red' },
idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime, badge: 'yellow' },
unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2, badge: 'neutral' }
};
// Pill-tab definitions for the rider listing tabs. Keeps brand purple for the
// "ALL" view and emerald for "Active" so the colour matches the count's meaning.
const TAB_META = [
{ key: 0, label: 'All Riders', icon: MdGroups, countKey: 'total' },
{ key: 1, label: 'Active', icon: MdCheckCircle, countKey: 'active' },
{ key: 2, label: 'Substitutes', icon: MdTwoWheeler, countKey: 'substitute' },
{ key: 3, label: 'Substitution History', icon: MdAccessTime, countKey: 'history' }
];
const HISTORY_STATUS_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'scheduled', label: 'Scheduled' }
];
const KPI_META = (summary) => [
{ key: 'total', label: 'Total Riders', color: BRAND, icon: MdOutlineGroups, value: summary?.total ?? 0 },
{ key: 'available', label: 'Available', color: '#10b981', icon: MdOutlineCheckCircle, value: summary?.available ?? 0 },
{ key: 'onDelivery', label: 'On Delivery', color: '#14b8a6', icon: MdTwoWheeler, value: summary?.onDelivery ?? 0 },
{ key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 }
];
// Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip
// pattern from the pricing page so the telemetry block reads at a glance.
const LogChip = ({ color, icon: Icon, label, value }) => (
<HStack
gap={1.5}
vAlign="center"
padding={2}
style={{ borderRadius: 12, 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 loadMoreRef = useRef();
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [locaName, setLocoName] = useState('All');
const [appId, setAppId] = useState(0);
const [tabvalue, setTabvalue] = useState(0);
const roleid = localStorage.getItem('roleid');
const totalCols = roleid == 1 ? 11 : 10;
const [logsRow, setLogsRow] = useState(null);
const [riderLogsdata, setRiderLogsdata] = useState(null);
const [historyDate, setHistoryDate] = useState(dayjs());
const [historyStatus, setHistoryStatus] = useState('all');
const [selectedDate, setSelectedDate] = useState(dayjs());
const [editingSub, setEditingSub] = useState(null);
const [newSubRider, setNewSubRider] = useState(null);
// The rider-substitution feature (/substitutions/*) has no equivalent in the
// new Doormile Express API at all — no such resource is documented. These
// handlers now no-op with an explanatory toast instead of calling jupiter.
const handleDeleteSubstitution = async (row) => {
if (window.confirm(`Are you sure you want to delete the substitution for ${row.absent_rider_name}?`)) {
OpenToast('Substitutions are not available on the Doormile Express API yet.', 'warning', 2500);
}
};
const handleUpdateSubstitutionSubmit = async () => {
if (!newSubRider) {
OpenToast('Please select a substitute rider', 'error', 2000);
return;
}
OpenToast('Substitutions are not available on the Doormile Express API yet.', 'warning', 2500);
setEditingSub(null);
setNewSubRider(null);
};
const [substituteAssignments, setSubstituteAssignments] = useState(() => {
try {
const saved = localStorage.getItem('rider_substitutions');
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
});
const handleAssignSubstitute = (activeRiderId, substituteRider) => {
const updated = {
...substituteAssignments,
[activeRiderId]: substituteRider
};
setSubstituteAssignments(updated);
localStorage.setItem('rider_substitutions', JSON.stringify(updated));
if (substituteRider) {
OpenToast(`Assigned ${substituteRider.username || substituteRider.fullname || 'Rider'} as substitute`, 'success', 2000);
} else {
OpenToast(`Removed substitute assignment`, 'info', 2000);
}
};
const handleFinalizeSuccess = () => {
setSubstituteAssignments({});
localStorage.removeItem('rider_substitutions');
queryClient.invalidateQueries({ queryKey: ['substitutionsHistory'] });
queryClient.invalidateQueries({ queryKey: ['subRidersLogs'] });
queryClient.invalidateQueries({ queryKey: ['activeSubstitutionsToday'] });
};
const { data: substituteRidersList } = useQuery({
queryKey: ['allRidersForSub', appId],
queryFn: async () => {
try {
const allRiders = (await getMilers()) || [];
const allowedIds = [1121, 772, 1116];
return allRiders.filter((rider) => allowedIds.includes(parseInt(rider.userid ?? rider.milerid)));
} catch (err) {
console.error(err);
return [];
}
}
});
// No rider login/checkout audit-log endpoint in the new API.
const { data: subRidersLogsData, isLoading: subRidersLogsLoading } = useQuery({
queryKey: ['subRidersLogs', appId, debouncedSearch, selectedDate.format('YYYY-MM-DD')],
queryFn: async () => []
});
// The /substitutions resource has no equivalent in the new API.
const { data: subHistoryData, isLoading: subHistoryLoading } = useQuery({
queryKey: ['substitutionsHistory', appId, historyDate, historyStatus],
queryFn: async () => []
});
const handleChangetab = (i) => {
setTabvalue(i);
setLogsRow(null);
};
// ==============================|| getallridersummary||============================== //
const { data: allRidersSummary, isLoading: riderSummarysLoading } = useQuery({
queryKey: ['allriders', appId, tabvalue],
queryFn: getallridersummary
});
// ==============================|| getRiderLogs (riders)||============================== //
// GET /admin/milers/:id/logs is confirmed live (Status: Done) — id is
// milerprofileid, not userid. A log entry's own field names are still
// unconfirmed (every rider in the tenant we tested against had an empty
// log history), so this falls back to the miler's own last-known position
// (currentlatitude/currentlongitude/lastlocationupdatedat/
// availabilitystatus — all confirmed fields) when there's no ping yet,
// rather than showing nothing.
const getRiderLogs = async (milerprofileid) => {
if (!milerprofileid) return;
setRiderLogsdata(null);
try {
const logs = await getMilerLogs(milerprofileid, undefined, undefined, 1);
const latest = Array.isArray(logs) ? logs[0] : null;
if (latest) {
setRiderLogsdata(latest);
} else {
const miler = await getMiler(milerprofileid);
setRiderLogsdata(
miler
? {
latitude: miler.currentlatitude,
longitude: miler.currentlongitude,
logdate: miler.lastlocationupdatedat || miler.lastpingat,
status: miler.availabilitystatus
}
: null
);
}
} catch (err) {
OpenToast('Could not load rider logs', 'error', 2000);
}
};
// ==============================|| fetchAllRiders||============================== //
// GET /admin/milers isn't paginated (fetchAllRiders always returns the full
// list in one shot) — getNextPageParam must not request a "next page" or it
// re-fetches and appends the identical full list again, duplicating every
// rider in `rows`.
const {
data: allRidersData,
isLoading: allRidersLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage
} = useInfiniteQuery({
queryKey: ['allriders', appId, debouncedSearch, tabvalue],
queryFn: fetchAllRiders,
getNextPageParam: () => undefined
});
const rows = (allRidersData?.pages.flatMap((page) => page.details || []) || []).filter(Boolean);
// GET /admin/milers accepts no search/status params — debouncedSearch and
// tabvalue only trigger a refetch (via the queryKey) that returns the exact
// same full list every time. Search and the "Active" tab were both silently
// no-ops until filtered client-side here.
const filteredRows = rows.filter((row) => {
// "Active" here means currently online/working (Available/Assigned/
// On_Pickup) — Offline and Blocked (confirmed live enum) both mean not
// working.
const status = String(row.availabilitystatus || '').toLowerCase();
if (tabvalue === 1 && (status === 'offline' || status === 'blocked')) return false;
if (!debouncedSearch) return true;
const q = debouncedSearch.toLowerCase();
return [row.displayname, row.phone].some((v) =>
String(v || '')
.toLowerCase()
.includes(q)
);
});
useEffect(() => {
if (!hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{
root: document.querySelector('.dt-table-scroll'),
rootMargin: '0px',
threshold: 1.0
}
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => {
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
};
}, [hasNextPage, fetchNextPage]);
// Per-row status meta — falls back to "unknown" if the rider's state key
// isn't in the palette (e.g. brand-new status string from the backend).
// Previously joined against a separate rider-status endpoint on every tab
// except "All Riders" — that endpoint has no equivalent in the new API and
// always returned null, so every rider on every other tab showed "Unknown"
// regardless of their real state. Now reads the miler's own
// `availabilitystatus` field directly on every tab (confirmed live — there
// is no `status` field on a miler at all).
const getRowStatusMeta = (row) => {
const key = (row?.availabilitystatus || '').toLowerCase();
return STATUS_META[key] || STATUS_META.unknown;
};
// PUT /admin/milers/:id/block — body shape isn't documented (Status: Built,
// never exercised against a real request), so no fields are sent beyond the
// id in the URL. Blocks whichever rider is passed after a confirm prompt.
const blockRiderMutation = useMutation({
// /admin/milers/:id routes key off milerprofileid, not userid (confirmed
// live — GET /admin/milers/:userid 404s; the miler's own userid field
// points at the underlying app-user account, a different resource).
mutationFn: (row) => blockMiler(row.milerprofileid),
onSuccess: (res) => {
if (res.success) {
OpenToast('Rider blocked', 'success', 2000);
queryClient.invalidateQueries({ queryKey: ['allriders'] });
} else {
OpenToast(res.message || 'Failed to block rider', 'error', 2000);
}
},
onError: (err) => {
OpenToast(err.response?.data?.message || err.message || 'Failed to block rider', 'error', 2000);
}
});
const handleBlockRider = (row) => {
if (window.confirm(`Block ${row.displayname || row.authname || 'this rider'}? They will not be able to log in to the miler app.`)) {
blockRiderMutation.mutate(row);
}
};
// PUT /admin/milers/:id/assign-vehicle — body shape isn't documented
// (Status: Built, never exercised), so this assumes `vehicleid` following
// the codebase's own <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>
);
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;
return (
<StatCard
key={item.key}
title={item.label}
value={item.value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={riderSummarysLoading}
/>
);
})}
</KpiGrid>
<DataCard
toolbar={
<FilterToolbar max={tabvalue === 3 ? 3 : 1}>
<HStack justify="end">
<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 && (
<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>
)}
{tabvalue === 3 && (
<DateInput
label="Select Date"
isLabelHidden
size="sm"
value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setHistoryDate(dayjs(v))}
/>
)}
</FilterToolbar>
}
tabs={
<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>
}
>
{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} />}
/>
)}
{filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const expanded = logsRow === row.milerprofileid;
return (
<MobileCard
key={row.userid ?? 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>
);
})}
{filteredRows?.length !== 0 && (
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage || hasNextPage ? (
<LoaderWithImage />
) : (
<Text type="supporting" weight="bold" color="secondary">
No more riders
</Text>
)}
</div>
)}
</MobileCardList>
) : (
<TableScroll minWidth={1300}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Rider</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell>Vehicle</TableHeaderCell>
<TableHeaderCell>Shift</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Time</TableHeaderCell>
<TableHeaderCell>Fare</TableHeaderCell>
<TableHeaderCell>Fuel</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
{roleid == 1 && <TableHeaderCell>Action</TableHeaderCell>}
</TableRow>
</TableHeader>
<TableBody>
{allRidersLoading && <TableLoader rows={6} columns={totalCols} />}
{filteredRows?.length === 0 && !allRidersLoading && (
<TableRow>
<TableCell colSpan={totalCols}>
<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 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const expanded = logsRow === row.milerprofileid;
return (
<Fragment key={row.userid ?? index}>
<TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}>
<TableCell>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Text>
</TableCell>
<TableCell>
<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
}}
>
#{row?.userid}
</div>
</TableCell>
<TableCell>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{row.displayname || row.authname || '—'}
</Text>
<Text type="supporting" color="secondary">
{row.phone || '—'}
</Text>
</VStack>
</TableCell>
<TableCell style={{ maxWidth: 220 }}>
<Tooltip content={row.address || ''}>
<VStack gap={0} padding={0}>
<Text type="supporting" weight="bold" color="secondary" maxLines={1}>
{row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.city || ''}
</Text>
</VStack>
</Tooltip>
</TableCell>
<TableCell>
<HStack gap={1} vAlign="center">
<AccentAvatar color="#0ea5e9" size={22}>
<MdTwoWheeler size={12} />
</AccentAvatar>
<Text type="supporting" weight="bold">
{row.defaultvehicletype || '—'}
</Text>
</HStack>
</TableCell>
<TableCell>
<Text type="supporting" weight="bold" color="secondary">
#{row.shiftid ?? '—'}
</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<VStack gap={0.5} padding={0} vAlign="center">
<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>
</TableCell>
<TableCell>
<Text type="supporting" weight="bold">
{row.basefare ?? '—'}
</Text>
</TableCell>
<TableCell>
<Text type="supporting" weight="bold">
{row.fuelcharge ?? '—'}
</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
</TableCell>
{roleid == 1 && (
<TableCell>
<RowActions row={row} expanded={expanded} />
</TableCell>
)}
</TableRow>
{expanded && tabvalue !== 0 && (
<TableRow>
<TableCell colSpan={totalCols} style={{ padding: 12 }}>
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
{filteredRows?.length !== 0 && (
<TableRow>
<TableCell colSpan={totalCols}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage || hasNextPage ? (
<LoaderWithImage />
) : (
<Text type="supporting" weight="bold" color="secondary">
No more riders
</Text>
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableScroll>
)}
</>
)}
</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;