enhancement
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
import { useMemo, useState, type ReactNode } 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,
|
||||
@@ -25,6 +22,7 @@ import { usePaged } from '@/components/usePaged';
|
||||
import { AssignBar } from '../AssignBar';
|
||||
import { assignability, assignedFrom, type Assignability } from '../assignDelivery';
|
||||
import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { count, money, moneyExact } from '../format';
|
||||
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
|
||||
@@ -37,7 +35,6 @@ import {
|
||||
groupByStore,
|
||||
isToday,
|
||||
lastSeen,
|
||||
shiftDay,
|
||||
stopAddress,
|
||||
stopCustomer,
|
||||
stopKey,
|
||||
@@ -48,7 +45,6 @@ import {
|
||||
toStops,
|
||||
UNASSIGNED,
|
||||
WAITING,
|
||||
ymd,
|
||||
type Group,
|
||||
type Stop,
|
||||
type ViewMode,
|
||||
@@ -56,10 +52,12 @@ import {
|
||||
import './dispatch.css';
|
||||
|
||||
/**
|
||||
* Dispatch — one day's deliveries, read three ways.
|
||||
* Dispatch — the 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
|
||||
* Scoped by the console's shared date filter in the top bar, like every other
|
||||
* page. A single day is the shape this was built for — that is how a round is
|
||||
* worked — and the labels adapt when a wider range is chosen rather than
|
||||
* calling a week of a rider's stops a "round". 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.
|
||||
*
|
||||
@@ -92,14 +90,14 @@ import './dispatch.css';
|
||||
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
|
||||
|
||||
export function DispatchPage() {
|
||||
const { branches, selected, current, tenantid } = useBranchScope();
|
||||
const [day, setDay] = useDayParam();
|
||||
const { branches, selected, tenantid } = useBranchScope();
|
||||
const dates = useDateScope();
|
||||
const [mode, setMode] = useState<ViewMode>('riders');
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<Stop | null>(null);
|
||||
|
||||
const query = tenantid
|
||||
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
|
||||
? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
|
||||
: undefined;
|
||||
|
||||
const deliveries = useDeliveries(query);
|
||||
@@ -121,6 +119,16 @@ export function DispatchPage() {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
/*
|
||||
A one-day range is still the shape this board was built for, so it keeps the
|
||||
wording it had: "Rounds", "on this day". A wider range is a legitimate thing
|
||||
to ask for and the groupings all still hold — but a rider's group is then
|
||||
several rounds run together, and the labels say so rather than pretending.
|
||||
*/
|
||||
const isOneDay = Boolean(
|
||||
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
|
||||
);
|
||||
|
||||
const stops = useMemo(
|
||||
() => toStops(deliveries.data ?? [], orders.data ?? []),
|
||||
[deliveries.data, orders.data],
|
||||
@@ -194,12 +202,6 @@ export function DispatchPage() {
|
||||
<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
|
||||
@@ -230,7 +232,7 @@ export function DispatchPage() {
|
||||
<KpiCard
|
||||
label="Stops"
|
||||
value={count(totals.stops)}
|
||||
note={isToday(day) ? 'today' : 'on this day'}
|
||||
note={isOneDay ? (isToday(dates.range.fromdate ?? '') ? 'today' : 'on this day') : 'in this range'}
|
||||
tone="accent"
|
||||
icon={<Package size={15} />}
|
||||
/>
|
||||
@@ -260,7 +262,13 @@ export function DispatchPage() {
|
||||
<div className="dispatch-body">
|
||||
<div className="dispatch-rail">
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
{mode === 'riders' ? 'Rounds' : mode === 'stores' ? 'Shops' : 'Customers'}
|
||||
{mode === 'riders'
|
||||
? isOneDay
|
||||
? 'Rounds'
|
||||
: 'Riders'
|
||||
: mode === 'stores'
|
||||
? 'Shops'
|
||||
: 'Customers'}
|
||||
</Text>
|
||||
<GroupList
|
||||
groups={groups}
|
||||
@@ -301,7 +309,11 @@ export function DispatchPage() {
|
||||
<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'}
|
||||
{groups.length > 0
|
||||
? 'Pick one to see its stops'
|
||||
: isOneDay
|
||||
? 'Nothing out on this day'
|
||||
: 'Nothing out in this range'}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
@@ -326,98 +338,6 @@ export function DispatchPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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({
|
||||
|
||||
Reference in New Issue
Block a user