enhancement

This commit is contained in:
2026-09-07 16:46:47 +05:30
parent 05ac76f5f0
commit c31696ce41
35 changed files with 926 additions and 986 deletions

View File

@@ -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({