dispatch fix
This commit is contained in:
571
src/features/store-admin/pages/DispatchPage.tsx
Normal file
571
src/features/store-admin/pages/DispatchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user