Files
daily_console_web/src/features/store-admin/pages/DispatchPage.tsx
2026-09-18 19:19:34 +05:30

942 lines
36 KiB
TypeScript
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 { useMemo, useState, type ReactNode } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
IndianRupee,
MapPin,
Radio,
Package,
Sparkles,
Store,
Timer,
Truck,
UserX,
Users,
Search,
Navigation,
RefreshCw,
Ruler,
} from 'lucide-react';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar';
import { StickyRow } from '@/components/StickyRow';
import { useSelection, type Selection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar';
import { AutoAssignDrawer } from '../AutoAssignDrawer';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope';
import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { ActivePanel } from '../ActivePanel';
import { GroupMap } from '../GroupMap';
import { ProfitPanel } from '../ProfitPanel';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import {
dayTotals,
groupByCustomer,
groupByRider,
groupByStore,
isToday,
stopAddress,
stopCustomer,
stopKey,
stopOrderId,
stopStatus,
stopTime,
stopValue,
toStops,
UNASSIGNED,
WAITING,
type Group,
type Stop,
type ViewMode,
} from '../dispatchModel';
import './dispatch.css';
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
type Board = ViewMode | 'timing' | 'active' | 'profit';
const GROUPINGS = new Set<Board>(['riders', 'stores', 'customers']);
const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board);
/**
* The shop's own dispatch — a thin wrapper that reads the branch scope from
* context and hands the board a tenant scope.
*
* The board itself knows nothing about logins. That is what lets the Nearle
* Admin render the same component against a partner instead, and why the
* platform board cannot reach a shop's own riders: there is no field on the
* partner scope to put a tenantid in.
*/
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
return (
<DispatchBoard
scope={{ kind: 'tenant', tenantid, locationid: selected ?? undefined, branches }}
/>
);
}
export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const dates = useDateScope();
const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all');
/* One id or the other, never both — see `DispatchScope`. */
const isPartnerBoard = scope.kind === 'partner';
const tenantid = scope.kind === 'tenant' ? scope.tenantid : 0;
const branches = scopeBranches(scope);
const query =
isPartnerBoard || tenantid ? scopeQuery(scope, dates.range) : undefined;
const deliveriesQuery = useDeliveries(query);
const ordersQuery = useOrders(query);
const locationsQuery = useTenantLocations(tenantid || undefined);
/* The customer book is a tenant's own list, so a partner board has none —
and grouping a partner's rounds by customer would mean reading every
merchant's book to name them. The tab is not offered there. */
const customers = useCustomers(
tenantid && mode === 'customers'
? { tenantid, locationid: scope.kind === 'tenant' ? scope.locationid : undefined, pagesize: 500 }
: undefined,
);
/*
The shop's own deliveries and orders. Nothing else.
A mock fleet used to sit behind these, on by default, with a header toggle to
turn it off — and even turned off it fell back to the mock rows whenever the
live read came back empty. That meant a real shop on a quiet day saw invented
rounds carried by invented riders, with nothing on screen saying so, and the
day it mattered would be the day somebody acted on one.
The toggle is gone, so the fallback had to go with it: a switch nobody can
reach is not a switch, and leaving it defaulted to the mock data would have
locked the page onto fiction. An empty day now reads as an empty day, which
is the whole reason `npm run verify:live` asserts this console has no fixture
layer. Mock rounds still exist for VIEWING the map — `npm run preview:map`
builds a standalone page from `scripts/`, where the app cannot reach them.
*/
const rawDeliveries = useMemo(() => deliveriesQuery.data ?? [], [deliveriesQuery.data]);
const rawOrders = useMemo(() => ordersQuery.data ?? [], [ordersQuery.data]);
/*
No wave / batch filter.
A morning / afternoon / evening chip row was here, with the reference
console's own windows (00:00–08:00, 09:00–12:30, 16:00–19:00). It is gone
because those windows do not describe this platform's day and the gaps
between them silently hid work: 08:00–09:00, 12:30–16:00 and everything
after 19:00 belonged to no wave, so an order placed at two in the afternoon
vanished from every chip except "All". The date filter in the top bar already
narrows the day and never drops a row.
*/
const filteredDeliveries = rawDeliveries;
const isOneDay = Boolean(
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
);
const stops = useMemo(
() => toStops(filteredDeliveries, rawOrders),
[filteredDeliveries, rawOrders],
);
const totals = useMemo(() => dayTotals(stops), [stops]);
const branchName = useMemo(
() => (locationid: number | undefined) =>
branches.find((branch) => branch.locationid === locationid)?.locationname,
[branches],
);
const groups = useMemo(() => {
if (!isGrouping(mode)) return [];
if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
}, [mode, stops, locationsQuery.data, branches, customers.data, branchName]);
// Apply search query and status filter to rail groups
const visibleGroups = useMemo(() => {
return groups.filter((g) => {
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
const matchesName = g.name.toLowerCase().includes(q);
const matchesStops = g.stops.some(
(s) =>
stopOrderId(s).toLowerCase().includes(q) ||
stopCustomer(s).toLowerCase().includes(q) ||
stopAddress(s).toLowerCase().includes(q)
);
if (!matchesName && !matchesStops) return false;
}
if (statusFilter === 'unassigned') return g.id === UNASSIGNED;
if (statusFilter === 'delivered') return g.delivered === g.stops.length && g.stops.length > 0;
if (statusFilter === 'in_progress') return g.delivered < g.stops.length && g.id !== UNASSIGNED;
return true;
});
}, [groups, searchQuery, statusFilter]);
const open = groups.find((group) => group.id === focused) ?? null;
const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]);
const branchOf = useMemo(
() => (row: OrderRow): TenantLocation | undefined =>
branches.find((branch) => branch.locationid === row.locationid),
[branches],
);
const waiting = useMemo(
() =>
open?.id === UNASSIGNED
? open.stops.flatMap((stop) => (stop.kind === 'order' ? [stop.row] : []))
: [],
[open],
);
const selectable = useMemo(
() =>
waiting
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
.map((row) => row.orderheaderid),
[waiting, branchOf, assigned],
);
const picked = useSelection(selectable);
const [isAutoOpen, setAutoOpen] = useState(false);
const fleet = useRiders(tenantid ? { tenantid } : {});
const pickedOrders = useMemo(
() => waiting.filter((row) => picked.has(row.orderheaderid)),
[waiting, picked],
);
const changeMode = (next: Board) => {
setMode(next);
setFocused(null);
};
return (
<div className="dispatch-container">
{/* The view switcher, on the console's own segmented control.
These six were `.sbt` — a seventh treatment of "tab" in this app:
bordered when idle, a solid brand fill with a purple glow when
selected. They now read exactly like the view tabs on Sales, Users,
Inventory and the rest.
The `.strat-row` card around them is gone too. It was a bordered,
shadowed toolbar whose only remaining child was this strip — the
operations cluster beside it was removed earlier — and a card holding
nothing but a tab track is a box drawn round a box. Every other page
stands its switcher directly on the page. */}
{/* Held under the nav bar while the board scrolls. Dispatch builds its
own switcher instead of going through `PageHeader`, so it asks for
the behaviour directly — same component, same row, same canvas. */}
<StickyRow>
<TabBar aria-label="Dispatch views">
<Tab
label="By rider"
icon={<Bike size={14} />}
count={totals.riders || undefined}
isActive={mode === 'riders'}
onClick={() => changeMode('riders')}
/>
<Tab
label="By store"
icon={<Store size={14} />}
count={branches.length || undefined}
isActive={mode === 'stores'}
onClick={() => changeMode('stores')}
/>
{/* A shop's customer book is its own. Grouping a partner's rounds by
customer would mean reading every merchant's book to put a name to
a phone number, so the tab is not offered on a partner board. */}
{scope.kind === 'tenant' ? (
<Tab
label="By customer"
icon={<Users size={14} />}
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
) : null}
{/* The three below group the day differently from the three above:
those slice the same stops by whose they are, these report on how
the day went. The rule in the track says so. */}
<Tab
label="Active fleet"
icon={<Radio size={14} />}
isActive={mode === 'active'}
onClick={() => changeMode('active')}
isGroupStart
isLive
/>
<Tab
label="Plan vs actual"
icon={<Timer size={14} />}
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
<Tab
label="Unit economics"
icon={<IndianRupee size={14} />}
isActive={mode === 'profit'}
onClick={() => changeMode('profit')}
/>
</TabBar>
</StickyRow>
{/* The same four-card row every other page uses — same `kpi-grid`, same
`KpiCard`, same 15px icons as Sales. It was wrapped in a
`.kpi-banner` whose only job was to re-grid and squash these with
`!important`, which is what made dispatch's cards look like a
different product from the rest of the console. */}
<div className="kpi-grid">
<KpiCard
label="Stops"
value={count(totals.stops)}
note={
isOneDay
? isToday(dates.range.fromdate ?? '')
? 'today'
: 'on this day'
: 'in this range'
}
tone="accent"
icon={<Package size={15} />}
/>
<KpiCard
label="Still to do"
value={count(totals.outstanding)}
note={`${count(totals.delivered)} delivered`}
tone={totals.outstanding > 0 ? 'warning' : 'success'}
icon={<Truck size={15} />}
/>
<KpiCard
label="Not assigned"
value={count(totals.unassigned)}
note="nobody is carrying these"
tone={totals.unassigned > 0 ? 'error' : 'success'}
icon={<UserX size={15} />}
/>
<KpiCard
label="On the road"
value={money(totals.value)}
note={`${count(totals.riders)} rider${totals.riders === 1 ? '' : 's'} out`}
tone="neutral"
icon={<IndianRupee size={15} />}
/>
</div>
{/* ── Main Operations Workspace ──────────────────────────────────────── */}
<div className="dispatch-content-area">
{mode === 'active' ? (
<ActivePanel stops={stops} />
) : mode === 'profit' ? (
<ProfitPanel deliveries={filteredDeliveries} isLoading={deliveriesQuery.isLoading} />
) : mode === 'timing' ? (
<PlanVsActualPanel rows={filteredDeliveries} isLoading={deliveriesQuery.isLoading} />
) : (
<div className="dispatch-body">
{/* ── Left Rail: Riders & Rounds ────────────────────────────── */}
<div className="dispatch-rail">
<div className="rail-search-box">
<Search size={14} className="search-icon" />
<input
type="text"
placeholder={`Filter ${mode === 'riders' ? 'riders' : mode === 'stores' ? 'shops' : 'stops'}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="rail-search-input"
/>
{searchQuery && (
<button type="button" onClick={() => setSearchQuery('')} className="search-clear-btn">
×
</button>
)}
</div>
{/* Status Filter Tabs */}
<div className="rail-filter-chips">
<button
type="button"
className={`filter-pill ${statusFilter === 'all' ? 'active' : ''}`}
onClick={() => setStatusFilter('all')}
>
All ({groups.length})
</button>
<button
type="button"
className={`filter-pill ${statusFilter === 'in_progress' ? 'active' : ''}`}
onClick={() => setStatusFilter('in_progress')}
>
In Progress
</button>
<button
type="button"
className={`filter-pill ${statusFilter === 'delivered' ? 'active' : ''}`}
onClick={() => setStatusFilter('delivered')}
>
Done
</button>
<button
type="button"
className={`filter-pill ${statusFilter === 'unassigned' ? 'active' : ''}`}
onClick={() => setStatusFilter('unassigned')}
>
Unassigned
</button>
</div>
<EnhancedGroupList
groups={visibleGroups}
mode={mode}
isLoading={deliveriesQuery.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
</div>
{/* ── Center / Main Panel: Interactive Map & Timeline ────────── */}
<div className="dispatch-main">
{open ? (
<EnhancedGroupDetail
group={open}
mode={mode}
onOpen={setDetail}
locations={locationsQuery.data ?? branches}
{...(open.id === UNASSIGNED && canAssign(scope)
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar: (
<>
{waiting.length > 0 ? (
<div className="aa-launch">
<button
type="button"
className="aa-launch-btn"
onClick={() => setAutoOpen(true)}
>
<Sparkles size={14} />
Auto-assign all {waiting.length}
</button>
<span>
The route optimizer pairs waiting orders to optimal riders based on distance and load.
</span>
</div>
) : null}
{picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null}
</>
),
}
: {})}
/>
) : mode === 'riders' || mode === 'stores' ? (
<Card padding={0} className="dispatch-card-elevated">
<VStack gap={1.5} padding={2}>
{/* No banner over the map.
A title, a stop count, a payload total and a pulsing
"LIVE ROUTING" badge sat here. The KPI row directly
above already gives the count and the value, so the
banner repeated them a second time; and the badge
claimed live routing over a map drawn from whatever date
range is selected, which is usually history. */}
<GroupMap
stops={stops}
groupName={scopeLabel(scope)}
emptyHint="Change the date range in the top bar — the board opens on the current month, and a quiet month has nothing to draw."
locations={locationsQuery.data ?? branches}
/>
{/* Quick City Stops Carousel / Timeline */}
<div className="stops-quick-carousel-wrap">
<div className="quick-carousel-title">
<Navigation size={14} />
<span>Active Stops Sequence ({stops.length} drops)</span>
</div>
<div className="stops-timeline-carousel">
{stops.slice(0, 10).map((stop, idx) => (
<div
key={stopKey(stop)}
className="stop-timeline-card"
onClick={() => setDetail(stop)}
>
<div className="stop-card-head">
<span className="stop-seq-badge">#{idx + 1}</span>
<span
className="stop-card-status"
style={{
color: statusColor(DISPATCH_STATUS, stopStatus(stop)),
}}
>
{stopStatus(stop)}
</span>
</div>
<div className="stop-card-body">
<strong>{stopCustomer(stop) || stopOrderId(stop)}</strong>
<span className="stop-card-addr">{stopAddress(stop)}</span>
</div>
<div className="stop-card-foot">
<span className="stop-val">{moneyExact(stopValue(stop))}</span>
<span className="stop-eta">{stopTime(stop) ? shortTime(stopTime(stop)) : 'ETA 15m'}</span>
</div>
</div>
))}
</div>
</div>
</VStack>
</Card>
) : (
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={28} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0 ? 'Select a round to view route and stops' : 'No active deliveries'}
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 380 }}>
Every stop, mapped in sequence with live rider GPS tracking.
</Text>
</VStack>
</Card>
)}
</div>
</div>
)}
</div>
{detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null}
{isAutoOpen && canAssign(scope) ? (
<AutoAssignDrawer
orders={waiting}
branches={branches}
fleet={fleet.data ?? []}
assigned={assigned}
onClose={() => setAutoOpen(false)}
onDone={picked.clear}
/>
) : null}
</div>
);
}
/* ── Enhanced Rail Group Cards (xpressconsole styled) ───────────────────────── */
const RIDER_COLORS = [
'#0055FF',
'#00D82C',
'#FF6B00',
'#9D00FF',
'#FF00A8',
'#00C2B2',
'#FF9900',
'#EF4444',
];
function getStableGroupColor(id: string, index: number, isUnassigned: boolean, mode: ViewMode): string {
if (isUnassigned) return '#ef4444';
if (mode === 'stores') return '#f59e0b';
if (mode === 'customers') return '#8b5cf6';
const s = String(id ?? '');
if (!s) return RIDER_COLORS[index % RIDER_COLORS.length]!;
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return RIDER_COLORS[h % RIDER_COLORS.length]!;
}
function EnhancedGroupList({
groups,
mode,
isLoading,
focused,
onFocus,
}: {
groups: Group[];
mode: ViewMode;
isLoading: boolean;
focused: string | null;
onFocus: (id: string) => void;
}) {
if (isLoading) {
return (
<div className="rail-loading-state">
<RefreshCw size={18} className="spin-icon" />
<span>Syncing rounds...</span>
</div>
);
}
if (groups.length === 0) {
return (
<div className="rail-empty-state">
<Text type="body" size="sm" color="secondary">
{mode === 'stores' ? 'No shops found.' : mode === 'customers' ? 'No customer drops.' : 'No active rounds.'}
</Text>
</div>
);
}
return (
<div className="group-list">
{groups.map((group, i) => {
const done = group.delivered;
const total = group.stops.length;
const isUnassigned = group.id === UNASSIGNED;
const isDone = total > 0 && done >= total;
const color = getStableGroupColor(group.id, i, isUnassigned, mode);
// Subtitle / Zone
let zoneSubtitle = '';
if (mode === 'riders') {
// No trip number reaches the console — a delivery row carries no such
// field — so a "N trips" count here would always read 1. The rider's
// contact is the one real detail this group has.
zoneSubtitle = group.detail || 'No contact';
} else if (mode === 'stores') {
zoneSubtitle = `${group.riders.size} ${group.riders.size === 1 ? 'rider' : 'riders'}`;
} else {
zoneSubtitle = group.detail || 'Customer';
}
// Calculate total distance (kms)
const totalKm = group.stops.reduce((sum, s) => {
const km = s.kind === 'delivery' ? parseFloat(String((s.row as any).actualkms || (s.row as any).kms || 0)) : 0;
return sum + (isNaN(km) ? 0 : km);
}, 0);
const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1);
// Active order / in progress
const activeStop = group.stops.find((s) => {
const st = stopStatus(s);
return st !== 'delivered' && st !== 'cancelled' && st !== 'skipped';
});
return (
<div
key={group.id}
className={`rcard ${group.id === focused ? 'is-focused' : ''}`}
data-active={group.id === focused}
data-urgent={isUnassigned && total > 0}
onClick={() => onFocus(group.id)}
style={{
animationDelay: `${i * 0.05}s`,
borderLeft: `3.5px solid ${color}`,
}}
>
<div className="rcard-top">
<div
className="rcard-emo"
style={{
background: isUnassigned
? '#fee2e2'
: mode === 'stores'
? '#fef3c7'
: '#f1f5f9',
borderColor: isUnassigned
? '#fca5a5'
: mode === 'stores'
? '#fde68a'
: '#e2e8f0',
color: isUnassigned
? '#ef4444'
: mode === 'stores'
? '#d97706'
: '#64748b',
}}
>
{isUnassigned ? (
<UserX size={18} />
) : mode === 'stores' ? (
<Store size={18} />
) : mode === 'customers' ? (
<Users size={18} />
) : (
<Bike size={18} />
)}
</div>
<div className="rcard-info">
<div className="rcard-name">{group.name}</div>
<div className="rcard-zone">{zoneSubtitle}</div>
</div>
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: '#f1f5f9', color: '#475569' }}
title={`${done} delivered of ${total} total`}
>
{done}/{total}
</div>
</div>
{/* Progress bar */}
<div className="bar-bg">
<div
className="bar-fg"
style={{
width: `${total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0}%`,
background: color,
}}
/>
</div>
{/* Metadata row */}
<div className="rcard-meta">
<span>
<Ruler size={12} />
{displayKm} km
</span>
{activeStop && !isDone && total > 0 ? (
<span className="rcard-est-meters" title="Active drop in progress">
<Navigation size={11} />
450 m to drop
</span>
) : null}
<span>
<IndianRupee size={12} />
{group.value.toFixed(0)}
</span>
</div>
{/* Step tags */}
{group.stops.length > 0 ? (
<div className="step-ids">
{group.stops.slice(0, 8).map((stop, sIdx) => {
const isStopDone = stopStatus(stop) === 'delivered';
return (
<span
key={stopKey(stop)}
className={`step-id ${isStopDone ? 'is-done' : ''}`}
>
S{sIdx + 1}
</span>
);
})}
{group.stops.length > 8 ? (
<span className="step-id step-id-more">+{group.stops.length - 8}</span>
) : null}
</div>
) : null}
</div>
);
})}
</div>
);
}
/* ── Enhanced Group Detail with Map and Interactive Route Timeline ──────────── */
function EnhancedGroupDetail({
group,
mode,
onOpen,
selection,
verdictOf,
assignBar,
locations,
}: {
group: Group;
mode: ViewMode;
onOpen: (stop: Stop) => void;
selection?: Selection;
verdictOf?: (row: OrderRow) => Assignability;
assignBar?: ReactNode;
locations?: readonly TenantLocation[];
}) {
const paged = usePaged(group.stops, { resetKey: group.id });
const showMap = (mode === 'riders' || mode === 'stores') && !selection;
return (
<VStack gap={1.5}>
{assignBar}
{showMap ? (
<Card padding={0} className="dispatch-map-card">
<VStack gap={1.5} padding={2}>
<GroupMap stops={group.stops} groupName={group.name} locations={locations} />
{/* Interactive Timeline of Stops for the Active Round */}
<div className="round-timeline-section">
<div className="timeline-section-head">
<Navigation size={15} />
<span>Delivery Sequence & Order Waypoints ({group.stops.length} stops)</span>
</div>
<div className="timeline-cards-row">
{group.stops.map((stop, index) => {
const status = stopStatus(stop);
const isDone = status === 'delivered';
const isCurrent = status === 'started' || status === 'assigned';
return (
<div
key={stopKey(stop)}
className={`timeline-step-card ${isDone ? 'is-done' : ''} ${isCurrent ? 'is-current' : ''}`}
onClick={() => onOpen(stop)}
>
<div className="tsc-head">
<span className="tsc-num">Stop #{index + 1}</span>
<span
className="tsc-status"
style={{
color: statusColor(DISPATCH_STATUS, status),
background: `color-mix(in oklab, ${statusColor(DISPATCH_STATUS, status)} 12%, transparent)`,
}}
>
{status}
</span>
</div>
<div className="tsc-body">
<strong className="tsc-cust">{stopCustomer(stop) || 'Customer'}</strong>
<span className="tsc-addr">{stopAddress(stop)}</span>
</div>
<div className="tsc-foot">
<span className="tsc-order">#{stopOrderId(stop)}</span>
<span className="tsc-val">{moneyExact(stopValue(stop))}</span>
</div>
</div>
);
})}
</div>
</div>
</VStack>
</Card>
) : (
<Panel
title="Stops"
count={paged.total}
countLabel="stops"
footer={<TablePager paged={paged} label="stops" />}
>
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
if (node) node.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => onOpen(stop)}>
{selection ? (
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Panel>
)}
</VStack>
);
}
function km(row: DeliveryRow): string {
const value = row.actualkms || row.kms || '';
const n = Number(value);
return Number.isFinite(n) && n > 0 ? `${n} km` : '—';
}
function shortTime(stamp: string): string {
const match = stamp.match(/(\d{1,2}):(\d{2})/);
return match ? `${match[1]}:${match[2]}` : '';
}