Files
doormilxpress_astryx/src/pages/doormile/riders/Riders.jsx
dharaneesh-r 1671a5f4b6 Milers rebrand, /doormile/home, and a set of console fixes
Two bodies of work that arrived in one working tree and are intertwined in
four files, so they commit together rather than pretending to a split the
diffs do not have.

TEAM WORK (pre-existing in the tree, uncommitted)

- "Riders" -> "Milers" across en.json and ~20 pages. Done carefully:
  Deliveries.jsx checks BOTH prefixes ("Miler #" and "Rider #") so rows
  written before the rename still render, and routes and query keys stay
  rider*/riderssummary — renaming those would have broken bookmarks and
  cache keys.
- MileTruth assistant rework: rename from "Doormile AI", HStack composer,
  maximise/minimise/reset controls, open state persisted to localStorage.
- New /doormile/home landing page; / and /doormile now redirect there
  instead of /doormile/dispatch. AdminLayout nav restructured with icons
  and descriptions.

FIXES

Tab strip was unreachable (Deliveries: Delivered and Cancelled)
  The pill variant is one non-wrapping inline-flex row. Given less width
  than it needs, flex COMPRESSED it — 872px squeezed into 780px, truncating
  labels inside the buttons — and with nothing scrollable no gesture could
  recover the last two tabs. Four of six tab pages had each hand-rolled the
  same wrapper; Deliveries and CompetitiveIntel had not. Fixed in the
  component, so no page can forget it. Verified in a browser at the real
  content width: 102px of scroll range, "Cancelled" reachable. 11 tests.

Home.jsx crash: Cannot read properties of null (reading 'flow')
  Introduced by the change below, which made deriveVisualData return null.
  All 12 .flow/.table dereferences are now behind visual?. gates.

Home.jsx presented fabricated figures as live operations
  deriveVisualData keyword-matched the prompt and returned hardcoded values
  for whole topics — revenue as a flat Rs 48,650, a workforce of 46 with 38
  active, six named hubs, four booking numbers — ignoring the real result it
  had been handed. Those branches sat ABOVE the one reading res.stats, so
  the correct code was unreachable: asking about revenue could not return
  the real number because a literal answered first.

  Two were worse than wrong figures. The default branch returned a staffing
  table for ANY unmatched question. And the no-answer path built a
  sourceCalls entry claiming /admin/milers had been called, status
  "complete", "46 milers scanned" — forging the provenance trail that exists
  so an operator can check where a number came from. A fabricated figure is
  a bug; a fabricated citation defeats the mechanism for catching one.

  190 lines removed. Every branch now reads the result and returns null when
  there is nothing to draw. No-answer says so; errors report as errors.

  Found while verifying: 'Inactive'.includes('active') is true, so every hub
  counted as active — carried from the original, whose own sample data
  tripped it (six hubs, one Inactive, shown as 6/6). Now an explicit
  vocabulary match, with unknown treated as down: a hub wrongly shown
  offline gets investigated, one wrongly shown online hides an outage.

Home launcher tiles named pages that do not exist
  Every tile now names its destination. Task Board -> Tripsheets,
  Staff -> Milers, Availability -> Milers Summary (it pointed at the same
  page as the tile beside it), Compliance -> Exceptions,
  Invoices -> Bookings, Payroll -> Profitability,
  AI Reports -> Orders Summary. Group headings: WORKFORCE SUITE -> FLEET,
  FINANCE & CRM -> CLIENTS & REVENUE, AI TOOLS -> REPORTS & AI. "Invoices",
  "Payroll" and "CRM" named systems Doormile does not run.

  Four hardcoded badges removed (3, 38, 7, "New"). None was computed. A red
  badge means "this many things need you", and one that never changes
  teaches operators to ignore the real ones.

Navbar MileTruth control misaligned, hover dead
  One cause: a 36px image (h-9 w-9 max-w-none) inside a 32px button. It
  stood proud of the bell and avatar AND covered its own button, leaving
  hover:bg-surface-sunken nowhere to render. Now 20px.

Assistant dock 30% -> 20%, and responsive
  clamp(320px, 20vw, 460px) rather than max(): the cap is what makes a large
  monitor work, since 20vw is 768px at 4K. Verified across seven screen
  classes — 384px/20% at 1920, capped at 460px beyond 2300px, full-width
  overlay at or below 900px. At 1024px the 320px floor wins (31%), because
  20vw would be 205px and too narrow for the composer.

Suggestion chips took a third of the panel
  .dai-suggestions was flex-direction:column, overriding the component's own
  wrap="wrap", so four chips became four full rows. Row + wrap, trimmed
  padding, ellipsis on the text span where text-overflow can act. Measured
  at the 320px floor: 4 rows/177px -> 2 rows/84px, nothing clipped.

Pricing and Customers were unreachable on mobile
  Moving them off the nav bar into the account dropdown removed them from
  the phone entirely — the mobile sheet builds from NAV plus NAV_GROUPS and
  they were in neither. Both stayed routed, so only a typed URL reached
  them. Now one SETTINGS_NAV array that the dropdown and the sheet share.

calculateDrivingDistance was called and never imported
  CreateOrder.jsx:371 — a ReferenceError on every customer-pickup order with
  a pinned collection address, thrown synchronously inside a useEffect so
  the .catch() on that chain could never see it. Neither the build nor the
  lint config catches an unbound identifier in a .jsx file: eslint.config.js
  spreads pluginJs.configs.recommended and then declares its own rules
  object, which replaces the spread rules wholesale, so no-undef has never
  run. A repo-wide sweep with it enabled found this was the only instance.
  Pre-existing; found while auditing the coordinate changes.

Dead code and stale docs
  ALL_DESTINATIONS (declared, never read; its comment claimed it was what
  the mobile sheet renders). --dai-panel-width (declared twice, read
  nowhere) and the max-width:1279px block that only set it. AdminLayout's
  header comment said "three grouped menus (Fleet Ops, Reports, Settings)"
  when there are two, and Settings is a section in the account menu — that
  difference in routing is what caused the mobile gap above.

VERIFIED

878 tests across 26 suites, build clean, no new lint problems. The tab
strip, dock width, chip wrapping and navbar sizing were measured in a real
browser against the shipped stylesheet. deriveVisualData is module-private,
so it was extracted and driven through 16 logic checks.

NOT VERIFIED

/doormile/home has never been rendered in a browser, and the responsive
pass covers the assistant panel only — no page has been viewed at any
breakpoint. Both need a signed-in session. DataTable carries its own
overflow-x-auto and only two fixed widths above 390px exist in src/pages,
but that is grep, not eyes.

STILL OPEN

customerAppBookings.js lost its customerstatus/customerstage grouping in
49ee0c5 and has not been restored, so the Bookings tabs still read only the
operational status. The Status column (Bookings.jsx:98, :415) was never
stage-aware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:21:21 +05:30

468 lines
16 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 React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import {
Ban, Bell, Bike, Car, CheckCircle2, CircleSlash, Pencil, Plus, Radio, Users,
} from 'lucide-react';
import {
Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton,
Input, KpiCard, Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds';
import {
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
useRiderSummaryCounts, useVehicles,
} from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
/**
* Riders — the milers who actually carry the parcels.
*
* Two identifiers matter here and they are not interchangeable: `userid` is
* what a booking's assignment records and what `assign-miler` wants, while
* `milerprofileid` is the primary key of this resource and what block, notify,
* assign-vehicle and logs all key on. Passing one where the other belongs 404s.
*
* The rider-substitution feature from the source console is deliberately absent:
* it has no backend endpoint at all, so there is nothing here to build against.
*/
/** "Active" means neither offline nor blocked — the same rule the counts use. */
const isActive = (rider) => !['offline', 'blocked'].includes(String(rider?.availabilitystatus || '').toLowerCase());
const riderName = (rider) => rider?.displayname || rider?.authname || '—';
/** A bare `HH:mm:ss` from the shift record, rendered as a clock time. */
const shiftTime = (value) =>
value ? dayjs(`${dayjs().format('YYYY-MM-DD')} ${value}`).format('hh:mm A') : '—';
const TABS = [
{ value: 'all', label: 'All riders' },
{ value: 'active', label: 'Active' },
{ value: 'blocked', label: 'Blocked' },
];
export default function Riders() {
const navigate = useNavigate();
const { data: riders = [], isLoading, isFetching } = useMilers();
const { data: summary } = useRiderSummaryCounts();
const { data: vehicles = [] } = useVehicles();
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search);
const [tab, setTab] = useState('all');
const [blockRow, setBlockRow] = useState(null);
const [vehicleRow, setVehicleRow] = useState(null);
const [selectedVehicle, setSelectedVehicle] = useState('');
const [notifyRow, setNotifyRow] = useState(null);
const [notice, setNotice] = useState({ title: 'DoormileXpress', message: '' });
const [logsRow, setLogsRow] = useState(null);
const blockMiler = useBlockMiler();
const assignVehicle = useAssignMilerVehicle();
const notifyMiler = useNotifyMiler();
const { selectedZone, isAllZones, matchesZone } = useZone();
const zoneRiders = useMemo(() => {
return (riders || []).filter(matchesZone);
}, [riders, matchesZone]);
const counts = useMemo(() => {
const list = zoneRiders;
return {
all: list.length,
active: list.filter(isActive).length,
blocked: list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked').length,
};
}, [zoneRiders]);
const rows = useMemo(() => {
let list = zoneRiders;
if (tab === 'active') list = list.filter(isActive);
else if (tab === 'blocked') {
list = list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked');
}
return list.filter((rider) =>
matchesQuery(rider, [riderName, 'phone', 'userid', 'city', 'suburb', 'defaultvehicletype'], debouncedSearch)
);
}, [zoneRiders, tab, debouncedSearch]);
const columns = useMemo(
() => [
{
key: 'rider',
header: 'Miler',
sortable: true,
accessor: riderName,
cell: (row) => (
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={riderName(row)} size="sm" />
<div className="min-w-0">
<p className="truncate text-body-sm font-semibold text-ink-1">{riderName(row)}</p>
<p className="text-caption text-ink-3">
{orDash(row.phone)} · #{row.userid}
</p>
</div>
</div>
),
},
{
key: 'address',
header: 'Base',
sortValue: (row) => row.suburb || row.address || '',
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm text-ink-2" title={row.address || ''}>
{row.suburb || row.address || '—'}
</p>
{row.city ? <p className="text-caption text-ink-3">{row.city}</p> : null}
</div>
),
hideBelow: 'lg',
},
{
key: 'defaultvehicletype',
header: 'Vehicle',
sortable: true,
accessor: (row) => orDash(row.defaultvehicletype),
hideBelow: 'md',
},
{
key: 'shift',
header: 'Shift',
sortValue: (row) => row.starttime || '',
accessor: (row) => `${shiftTime(row.starttime)} – ${shiftTime(row.endtime)}`,
hideBelow: 'lg',
},
{
key: 'basefare',
header: 'Base fare',
sortValue: (row) => Number(row.basefare) || 0,
accessor: (row) => currency(row.basefare),
align: 'right',
hideBelow: 'lg',
},
{
key: 'availabilitystatus',
header: 'Status',
sortable: true,
cell: (row) => <StatusBadge status={row.availabilitystatus} dot size="sm" />,
},
{
key: 'actions',
header: 'Actions',
align: 'right',
cell: (row) => (
<div className="flex justify-end gap-1">
<IconButton
label="Edit miler"
icon={Pencil}
variant="ghost"
size="sm"
onClick={() => navigate('/doormile/milers/edit', { state: { rider: row } })}
/>
<IconButton
label="Assign vehicle"
icon={Car}
variant="ghost"
size="sm"
onClick={() => {
setVehicleRow(row);
setSelectedVehicle('');
}}
/>
<IconButton
label="Send a notification"
icon={Bell}
variant="ghost"
size="sm"
onClick={() => {
setNotifyRow(row);
setNotice({ title: 'DoormileXpress', message: '' });
}}
/>
<IconButton
label="Live logs"
icon={Radio}
variant="ghost"
size="sm"
onClick={() => setLogsRow(row)}
/>
<IconButton label="Block miler" icon={Ban} variant="ghost" size="sm" onClick={() => setBlockRow(row)} />
</div>
),
},
],
[navigate]
);
return (
<Stack space="lg">
<PageHeader
icon={Bike}
title="Milers"
subtitle="Who is on the road, what they are on, and where they are"
actions={
<Button onClick={() => navigate('/doormile/milers/create')}>
<Plus className="mr-1.5 h-4 w-4" /> New miler
</Button>
}
/>
<Grid cols={4}>
<KpiCard
icon={Users}
value={summary?.total ?? counts.all}
label="Total milers"
tone="brand"
index={0}
loading={isLoading}
onClick={() => setTab('all')}
/>
<KpiCard
icon={CheckCircle2}
value={summary?.available ?? counts.active}
label="Available now"
tone="success"
index={1}
loading={isLoading}
/>
<KpiCard
icon={Bike}
value={
summary?.onDelivery ??
zoneRiders.filter(
(r) =>
String(r.availabilitystatus || '').toLowerCase() === 'on_delivery' ||
String(r.availabilitystatus || '').toLowerCase() === 'ondelivery'
).length
}
label="On delivery"
tone="accent"
index={2}
loading={isLoading}
/>
<KpiCard
icon={CircleSlash}
value={summary?.inactive ?? counts.blocked}
label="Offline or blocked"
tone="neutral"
index={3}
loading={isLoading}
onClick={() => setTab('blocked')}
/>
</Grid>
<Stack space="sm">
{/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx
for why two `shrink-0` halves scrolled the whole page on a phone. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<ZoneSelector />
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, phone, id or city"
className="w-full sm:w-64"
/>
</div>
</div>
<p className="text-caption text-ink-3">
{rows.length === (counts[tab] ?? counts.all)
? `${counts[tab] ?? counts.all} milers`
: `${rows.length} of ${counts[tab] ?? counts.all} milers`}
</p>
<DataTable
columns={columns}
rows={rows}
getRowId={(row) => row.milerprofileid ?? row.userid}
loading={isLoading}
refreshing={isFetching && !isLoading}
pageSize={10}
defaultSort={{ key: 'rider', direction: 'asc' }}
isFiltered={Boolean(debouncedSearch)}
onClearFilters={() => setSearch('')}
emptyState={
debouncedSearch ? undefined : (
<EmptyState
icon={Bike}
title="No milers here"
description="Onboard a miler to start assigning deliveries."
action={{ label: 'New miler', icon: Plus, onClick: () => navigate('/doormile/milers/create') }}
/>
)
}
/>
</Stack>
<ConfirmModal
open={Boolean(blockRow)}
onOpenChange={(open) => !open && setBlockRow(null)}
title="Block this miler?"
description={`${riderName(blockRow || {})} will stop receiving assignments until they are unblocked.`}
confirmLabel="Block miler"
busy={blockMiler.isPending}
onConfirm={() => {
blockMiler.mutate(
{ id: blockRow.milerprofileid, data: {} },
{ onSettled: () => setBlockRow(null) }
);
}}
/>
<Modal
open={Boolean(vehicleRow)}
onOpenChange={(open) => !open && setVehicleRow(null)}
title="Assign a vehicle"
description={riderName(vehicleRow || {})}
icon={Car}
busy={assignVehicle.isPending}
footer={
<>
<Button variant="ghost" onClick={() => setVehicleRow(null)}>
Cancel
</Button>
<Button
onClick={() =>
assignVehicle.mutate(
{ id: vehicleRow.milerprofileid, data: { vehicleid: Number(selectedVehicle) } },
{ onSuccess: (res) => res?.success !== false && setVehicleRow(null) }
)
}
disabled={!selectedVehicle || assignVehicle.isPending}
>
Assign vehicle
</Button>
</>
}
>
<Field label="Vehicle">
<Select value={selectedVehicle} onValueChange={setSelectedVehicle}>
<SelectTrigger>
<SelectValue placeholder="Choose a vehicle" />
</SelectTrigger>
<SelectContent>
{(vehicles || []).map((vehicle) => (
<SelectItem key={vehicle.vehicleid} value={String(vehicle.vehicleid)}>
{vehicle.vehicleno || `Vehicle #${vehicle.vehicleid}`}
{vehicle.vehicletype ? ` · ${vehicle.vehicletype}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</Modal>
<Modal
open={Boolean(notifyRow)}
onOpenChange={(open) => !open && setNotifyRow(null)}
title="Notify this miler"
description={`${riderName(notifyRow || {})} — delivered to the miler app as a push notification.`}
icon={Bell}
busy={notifyMiler.isPending}
footer={
<>
<Button variant="ghost" onClick={() => setNotifyRow(null)}>
Cancel
</Button>
<Button
onClick={() =>
notifyMiler.mutate(
{
milerProfileId: notifyRow.milerprofileid,
title: notice.title,
message: notice.message,
},
{ onSuccess: (res) => res?.success !== false && setNotifyRow(null) }
)
}
disabled={notifyMiler.isPending || !notice.message.trim()}
>
Send notification
</Button>
</>
}
>
<Stack space="default">
<Field label="Title">
<Input value={notice.title} onChange={(e) => setNotice({ ...notice, title: e.target.value })} />
</Field>
<Field label="Message">
<Textarea
rows={4}
value={notice.message}
onChange={(e) => setNotice({ ...notice, message: e.target.value })}
placeholder="Orders have been placed for delivery. Kindly accept and process deliveries."
/>
</Field>
</Stack>
</Modal>
<RiderLogsDrawer rider={logsRow} onClose={() => setLogsRow(null)} />
</Stack>
);
}
/**
* The rider's own ping history — position and status over time.
*
* A log entry has no documented field names, so each row renders whichever of
* the plausible ones is present rather than assuming a shape that would render
* a column of blanks against a real response.
*/
function RiderLogsDrawer({ rider, onClose }) {
const { data: logs = [], isLoading } = useMilerLogs(rider?.milerprofileid, undefined, undefined, 50);
return (
<Drawer
open={Boolean(rider)}
onOpenChange={(next) => !next && onClose()}
title={`Live logs · ${riderName(rider || {})}`}
description="The most recent pings this rider's app has sent."
icon={Radio}
size="lg"
>
{isLoading ? (
<p className="text-body-sm text-ink-3">Loading logs…</p>
) : !logs || logs.length === 0 ? (
<EmptyState icon={Radio} size="sm" title="No pings recorded" description="This rider has not reported a position yet." />
) : (
<Stack space="sm">
{logs.map((log, index) => (
<Surface key={log.logid ?? index} variant="subtle" padding="sm" radius="lg">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">
{orDash(log.status ?? log.availabilitystatus ?? log.event)}
</p>
<p className="text-caption text-ink-3">
{log.latitude ?? log.currentlatitude ?? '—'}, {log.longitude ?? log.currentlongitude ?? '—'}
</p>
</div>
<p className="shrink-0 text-caption text-ink-4">
{formatDoormileTimestamp(log.logdate ?? log.lastpingat ?? log.createdat, 'DD MMM, hh:mm A')}
</p>
</div>
</Surface>
))}
</Stack>
)}
</Drawer>
);
}