942 lines
36 KiB
TypeScript
942 lines
36 KiB
TypeScript
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]}` : '';
|
||
}
|