dispatch fix

This commit is contained in:
2026-09-05 15:38:40 +05:30
parent 5006a4f0d4
commit d3785cc303
8 changed files with 1407 additions and 0 deletions

View File

@@ -0,0 +1,571 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
ChevronLeft,
ChevronRight,
IndianRupee,
MapPin,
Package,
Store,
Truck,
UserX,
Users,
} from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { useCustomers, useDeliveries, useTenantLocations } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { shortAge } from '../posStatus';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import {
dayTotals,
groupByCustomer,
groupByRider,
groupByStore,
isToday,
lastSeen,
shiftDay,
statusOf,
UNASSIGNED,
ymd,
type Group,
type ViewMode,
} from '../dispatchModel';
import './dispatch.css';
/**
* Dispatch — one day's deliveries, read three ways.
*
* A day at a time, because that is how a round is worked: everything here is
* scoped to a single date, not a range. No batch windows — order placement in
* this tenant clusters so hard into one hour that a morning/afternoon/evening
* split would put ninety per cent in one bucket and call it navigation.
*
* ── Why three groupings and not one list ────────────────────────────────────
*
* The same rows answer three different questions and no single ordering answers
* more than one of them: which shop is busy (stores), who is still waiting
* (customers), and what is each rider carrying (riders). The reference console
* this is modelled on offers the first two; the third is the one our own
* Deliveries tab could never answer, since it lists jobs flat with a rider
* column and no way to see a round.
*
* ── What this page deliberately is not ──────────────────────────────────────
*
* There is no map yet. Every drop coordinate in production today sits within
* about four metres of every other — one test customer ordering repeatedly —
* so a map would render one pin with six underneath and imply a precision the
* data does not have. The grouping, the rounds and the rider positions all work
* without one, and the map is worth adding the day real drops spread out.
*/
export function DispatchPage() {
const { branches, selected, current, tenantid } = useBranchScope();
const [day, setDay] = useDayParam();
const [mode, setMode] = useState<ViewMode>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<DeliveryRow | null>(null);
const query = tenantid
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
: undefined;
const deliveries = useDeliveries(query);
const locations = useTenantLocations(tenantid || undefined);
// Only fetched for the customer view — the book runs to thousands of rows and
// no other grouping needs it.
const customers = useCustomers(
tenantid && mode === 'customers'
? { tenantid, locationid: selected ?? undefined, pagesize: 500 }
: undefined,
);
const rows = useMemo(() => deliveries.data ?? [], [deliveries.data]);
const totals = useMemo(() => dayTotals(rows), [rows]);
const branchName = useMemo(
() => (locationid: number | undefined) =>
branches.find((branch) => branch.locationid === locationid)?.locationname,
[branches],
);
const groups = useMemo(() => {
if (mode === 'stores') return groupByStore(rows, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(rows, customers.data ?? [], branchName);
return groupByRider(rows);
}, [mode, rows, locations.data, branches, customers.data, branchName]);
const open = groups.find((group) => group.id === focused) ?? null;
const changeMode = (next: ViewMode) => {
setMode(next);
// A group id means nothing across groupings — a branch id is not a rider
// id — so the focus is dropped rather than carried into nonsense.
setFocused(null);
};
return (
<VStack gap={3}>
<PageHeader
title="Dispatch"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="One day of deliveries — by rider, by shop, or by customer."
isLive
actions={
<DayStepper day={day} onChange={setDay} />
}
tabs={
<HStack gap={0.5} wrap="wrap">
<ModeTab
label="By rider"
icon={<Bike size={14} />}
badge={totals.riders || undefined}
isActive={mode === 'riders'}
onClick={() => changeMode('riders')}
/>
<ModeTab
label="By store"
icon={<Store size={14} />}
badge={branches.length || undefined}
isActive={mode === 'stores'}
onClick={() => changeMode('stores')}
/>
<ModeTab
label="By customer"
icon={<Users size={14} />}
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
</HStack>
}
/>
<div className="kpi-grid">
<KpiCard
label="Stops"
value={count(totals.stops)}
note={isToday(day) ? 'today' : 'on this day'}
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={count(totals.riders)}
note={money(totals.value) + ' in play'}
tone="neutral"
icon={<IndianRupee size={15} />}
/>
</div>
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail group={open} mode={mode} onOpen={setDetail} />
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0 ? 'Pick one to see its stops' : 'Nothing out on this day'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{groups.length > 0
? 'Every stop, in the order it was assigned, with where the rider last reported in.'
: 'Deliveries appear here once orders are assigned to a rider.'}
</Text>
</VStack>
</Card>
)}
</div>
</div>
{detail ? (
<OrderDetailDrawer row={detail} kind="delivery" onClose={() => setDetail(null)} />
) : null}
</VStack>
);
}
/* ── The day ─────────────────────────────────────────────────────────────── */
/**
* Which day is on screen, held in the URL.
*
* The same reasoning `BranchScope` gives for the branch: "Dispatch, last
* Tuesday" has to survive being pasted into a chat, and a reload mid-shift must
* not silently throw the operator back to today while they are reading a round
* that belongs to another date.
*
* A malformed or future value falls back to today rather than 400ing the
* delivery read — the param is user-editable, so it is untrusted input.
*/
function useDayParam(): [string, (next: string) => void] {
const [params, setParams] = useSearchParams();
const today = ymd(new Date());
const raw = params.get('day') ?? '';
const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) && raw <= today ? raw : today;
const set = (next: string) => {
const clamped = next > today ? today : next;
const merged = new URLSearchParams(params);
// Today is the default, so it is left OUT of the URL: the common case gets
// a clean address, and only a deliberate look back carries a param.
if (clamped === today) merged.delete('day');
else merged.set('day', clamped);
setParams(merged, { replace: true });
};
return [day, set];
}
/**
* A day at a time.
*
* Not a range picker: a round belongs to a date, and the arrows are how an
* operator checks yesterday without thinking about a calendar. Forward is
* disabled on today — dispatch has nothing to say about tomorrow.
*/
function DayStepper({ day, onChange }: { day: string; onChange: (day: string) => void }) {
const today = isToday(day);
const label = new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
});
return (
<div className="day-stepper">
<button type="button" aria-label="Previous day" onClick={() => onChange(shiftDay(day, -1))}>
<ChevronLeft size={15} />
</button>
{/*
The label doubles as the picker. Arrows answer "yesterday", which is
most of the traffic; a date a month back would otherwise be thirty
clicks. The input sits transparent over the label rather than replacing
it, so the day still reads as "Sat, Sep 5" instead of as a form field,
and clicking it opens the browser's own calendar — a real control,
keyboard-reachable, with no picker to write or to get wrong.
*/}
<span className="day-label">
<span aria-hidden>{label}</span>
{today ? <em aria-hidden>today</em> : null}
<input
type="date"
aria-label="Pick a day"
value={day}
max={ymd(new Date())}
onChange={(event) => {
// Clearing the field yields '' — keep the day rather than blanking
// the board.
if (event.target.value) onChange(event.target.value);
}}
/>
</span>
<button
type="button"
aria-label="Next day"
disabled={today}
onClick={() => onChange(shiftDay(day, 1))}
>
<ChevronRight size={15} />
</button>
{!today ? (
<button type="button" className="day-today" onClick={() => onChange(ymd(new Date()))}>
Today
</button>
) : null}
</div>
);
}
/* ── The rail ────────────────────────────────────────────────────────────── */
function GroupList({
groups,
mode,
isLoading,
focused,
onFocus,
}: {
groups: Group[];
mode: ViewMode;
isLoading: boolean;
focused: string | null;
onFocus: (id: string) => void;
}) {
if (isLoading) {
return (
<Text type="body" size="sm" color="secondary">
Reading the day…
</Text>
);
}
if (groups.length === 0) {
return (
<Text type="body" size="sm" color="secondary">
{mode === 'stores' ? 'No shops.' : mode === 'customers' ? 'Nobody ordered.' : 'No rounds.'}
</Text>
);
}
return (
<div className="group-list">
{groups.map((group) => {
const done = group.delivered;
const total = group.deliveries.length;
return (
<button
key={group.id}
type="button"
className="group-card"
data-active={group.id === focused}
data-urgent={group.id === UNASSIGNED && total > 0}
aria-pressed={group.id === focused}
onClick={() => onFocus(group.id)}
>
<span className="group-name">
{group.name}
{group.detail ? <em>{group.detail}</em> : null}
</span>
<span className="group-count">
{total > 0 ? (
<>
<strong>{done}</strong>/{total}
</>
) : (
<span className="group-quiet">quiet</span>
)}
</span>
{/* One bar per status, widths proportional. It answers "how far
through is this round" without a number to read. */}
{total > 0 ? (
<span className="group-bar">
{Object.entries(group.statuses).map(([status, n]) => (
<i
key={status}
style={{
width: `${(n / total) * 100}%`,
background: statusColor(DELIVERY_STATUS, status),
}}
title={`${n} ${status}`}
/>
))}
</span>
) : null}
</button>
);
})}
</div>
);
}
/* ── The focused group ───────────────────────────────────────────────────── */
function GroupDetail({
group,
mode,
onOpen,
}: {
group: Group;
mode: ViewMode;
onOpen: (row: DeliveryRow) => void;
}) {
const seen = lastSeen(group.deliveries);
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<VStack gap={0}>
<Text type="label" size="lg" weight="semibold">
{group.name}
</Text>
{group.detail ? (
<Text type="body" size="xsm" color="secondary">
{group.detail}
</Text>
) : null}
</VStack>
<Text type="body" size="sm" color="secondary">
{group.deliveries.length} stop{group.deliveries.length === 1 ? '' : 's'} ·{' '}
{moneyExact(group.value)}
</Text>
</HStack>
{/* Rider position, only in the rider view and only when reported.
Labelled with its age and the status that produced it, because it
is a last-known point and not a live feed — drawing it as a dot on
a map would claim otherwise. */}
{mode === 'riders' ? (
seen ? (
<div className="last-seen">
<MapPin size={14} />
<div>
<strong>
{seen.lat.toFixed(5)}, {seen.lon.toFixed(5)}
</strong>
<span>
reported at “{seen.status}”
{seen.at ? ` · ${ageOf(seen.at)}` : ''}
{seen.orderid ? ` · ${seen.orderid}` : ''}
</span>
</div>
<a
href={`https://www.openstreetmap.org/?mlat=${seen.lat}&mlon=${seen.lon}#map=16/${seen.lat}/${seen.lon}`}
target="_blank"
rel="noreferrer"
>
Open map
</a>
</div>
) : (
<div className="last-seen" data-empty="true">
<MapPin size={14} />
<div>
<strong>No position reported</strong>
<span>
A rider's location arrives only when they move a job along, so a round with no
updates yet has none.
</span>
</div>
</div>
)
) : null}
</VStack>
</Card>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
<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>
{group.deliveries.map((row, index) => (
<tr key={row.deliveryid} onClick={() => onOpen(row)}>
<td className="num">{index + 1}</td>
<td>
<strong>{row.orderid || `#${row.orderheaderid}`}</strong>
<span>{row.assigntime ? shortTime(row.assigntime) : ''}</span>
</td>
<td>
<strong>{row.deliverycustomer || '—'}</strong>
<span>{row.deliveryaddress || row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>{row.ridername || <em className="muted">not assigned</em>}</td>
) : null}
<td className="num">{km(row)}</td>
<td className="num">{moneyExact(row.deliveryamt ?? 0)}</td>
<td>
<span
className="stop-status"
style={{
color: statusColor(DELIVERY_STATUS, statusOf(row)),
background: `color-mix(in oklab, ${statusColor(DELIVERY_STATUS, statusOf(row))} 12%, transparent)`,
}}
>
{statusOf(row)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
/** Planned distance, or the actual once it exists. Both arrive as strings. */
function km(row: DeliveryRow): string {
const value = row.actualkms || row.kms || '';
const n = Number(value);
return Number.isFinite(n) && n > 0 ? `${n}` : '—';
}
function shortTime(stamp: string): string {
const match = stamp.match(/(\d{1,2}):(\d{2})/);
return match ? `${match[1]}:${match[2]}` : '';
}
function ageOf(stamp: string): string {
const at = new Date(stamp.replace(' ', 'T')).getTime();
if (Number.isNaN(at)) return '';
return `${shortAge(Math.max(0, Date.now() - at))} ago`;
}
/* ── Chrome ──────────────────────────────────────────────────────────────── */
function ModeTab({
label,
icon,
badge,
isActive,
onClick,
}: {
label: string;
icon: React.ReactNode;
badge?: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="mode-tab" data-active={isActive} onClick={onClick}>
{icon}
{label}
{badge ? <span>{badge}</span> : null}
</button>
);
}