pagenation

This commit is contained in:
2026-09-07 11:00:44 +05:30
parent d3785cc303
commit 8defbd418f
22 changed files with 1094 additions and 226 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
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';
@@ -16,10 +16,15 @@ import {
UserX,
Users,
} from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { useCustomers, useDeliveries, useTenantLocations } from '@/queries/hooks';
import { useSelection, type Selection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
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 { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
@@ -33,10 +38,19 @@ import {
isToday,
lastSeen,
shiftDay,
statusOf,
stopAddress,
stopCustomer,
stopKey,
stopOrderId,
stopStatus,
stopTime,
stopValue,
toStops,
UNASSIGNED,
WAITING,
ymd,
type Group,
type Stop,
type ViewMode,
} from '../dispatchModel';
import './dispatch.css';
@@ -66,18 +80,38 @@ import './dispatch.css';
* 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.
*/
/**
* The delivery ladder plus the one status that is not on it.
*
* `waiting` is red because it is the only status on this board that means
* nobody has done anything — every other colour describes work in motion. It is
* added here rather than in `DELIVERY_STATUS` because it is not a delivery
* status: no `deliveries` row ever holds it, and putting it there would offer
* it to the delivery filters, where it can never match.
*/
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
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 [detail, setDetail] = useState<Stop | null>(null);
const query = tenantid
? { tenantid, locationid: selected ?? undefined, fromdate: day, todate: day, pagesize: 200 }
: undefined;
const deliveries = useDeliveries(query);
/*
Orders too, over the same window.
Not redundant with the deliveries read: a `deliveries` row only exists once
somebody has been assigned, so an order nobody has picked up is invisible to
it. Without this the board showed "Nothing out on this day" while R mart had
29 orders waiting, and "Not assigned" could never be anything but zero.
*/
const orders = useOrders(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.
@@ -87,8 +121,11 @@ export function DispatchPage() {
: undefined,
);
const rows = useMemo(() => deliveries.data ?? [], [deliveries.data]);
const totals = useMemo(() => dayTotals(rows), [rows]);
const stops = useMemo(
() => toStops(deliveries.data ?? [], orders.data ?? []),
[deliveries.data, orders.data],
);
const totals = useMemo(() => dayTotals(stops), [stops]);
const branchName = useMemo(
() => (locationid: number | undefined) =>
@@ -97,13 +134,55 @@ export function DispatchPage() {
);
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]);
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
}, [mode, stops, locations.data, branches, customers.data, branchName]);
const open = groups.find((group) => group.id === focused) ?? null;
/* ── Assigning, from the queue itself ────────────────────────────────────
The same AssignBar the Orders page uses, on the same `assignability` rule.
Reused rather than rebuilt: a second assign path would be a second place
for the delivery-status vocabulary to drift, and writing the wrong status
means the rider's app never sees the job. */
const assigned = useMemo(() => assignedFrom(deliveries.data ?? []), [deliveries.data]);
const branchOf = useMemo(
() => (row: OrderRow): TenantLocation | undefined =>
branches.find((branch) => branch.locationid === row.locationid),
[branches],
);
/** The waiting orders on screen — only the unassigned group holds any. */
const waiting = useMemo(
() =>
open?.id === UNASSIGNED
? open.stops.flatMap((stop) => (stop.kind === 'order' ? [stop.row] : []))
: [],
[open],
);
/*
Ticks are built from what can ACTUALLY be sent, not from every waiting row.
Handing the selection every row ticked the disabled boxes too — they went
grey-and-checked and the bar counted more than it would assign, which is how
a person stops trusting the ticks. The Orders page learnt this the hard way.
*/
const selectable = useMemo(
() =>
waiting
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
.map((row) => row.orderheaderid),
[waiting, branchOf, assigned],
);
const picked = useSelection(selectable);
const pickedOrders = useMemo(
() => waiting.filter((row) => picked.has(row.orderheaderid)),
[waiting, picked],
);
const changeMode = (next: ViewMode) => {
setMode(next);
// A group id means nothing across groupings — a branch id is not a rider
@@ -194,7 +273,27 @@ export function DispatchPage() {
<div className="dispatch-main">
{open ? (
<GroupDetail group={open} mode={mode} onOpen={setDetail} />
<GroupDetail
group={open}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
@@ -221,7 +320,7 @@ export function DispatchPage() {
</div>
{detail ? (
<OrderDetailDrawer row={detail} kind="delivery" onClose={() => setDetail(null)} />
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null}
</VStack>
);
@@ -353,7 +452,7 @@ function GroupList({
<div className="group-list">
{groups.map((group) => {
const done = group.delivered;
const total = group.deliveries.length;
const total = group.stops.length;
return (
<button
key={group.id}
@@ -386,7 +485,7 @@ function GroupList({
key={status}
style={{
width: `${(n / total) * 100}%`,
background: statusColor(DELIVERY_STATUS, status),
background: statusColor(DISPATCH_STATUS, status),
}}
title={`${n} ${status}`}
/>
@@ -406,12 +505,31 @@ function GroupDetail({
group,
mode,
onOpen,
selection,
verdictOf,
assignBar,
}: {
group: Group;
mode: ViewMode;
onOpen: (row: DeliveryRow) => void;
onOpen: (stop: Stop) => void;
/**
* Ticks, and the bar they feed. Present only on the waiting queue — every
* other group is work already handed over, and offering to assign it again
* would be offering to double-book a rider.
*/
selection?: Selection;
/**
* The whole verdict, not just a boolean — a row that cannot be assigned has
* to be able to say WHY on hover, and re-deriving the reason here with
* whatever arguments were to hand is how a tooltip ends up confidently wrong.
*/
verdictOf?: (row: OrderRow) => Assignability;
assignBar?: ReactNode;
}) {
const seen = lastSeen(group.deliveries);
const seen = lastSeen(group.stops);
// Reset to page 1 whenever a different group is opened — page 3 of the last
// rider's round means nothing on this one.
const paged = usePaged(group.stops, { resetKey: group.id });
return (
<VStack gap={1.5}>
@@ -429,7 +547,7 @@ function GroupDetail({
) : null}
</VStack>
<Text type="body" size="sm" color="secondary">
{group.deliveries.length} stop{group.deliveries.length === 1 ? '' : 's'} ·{' '}
{group.stops.length} stop{group.stops.length === 1 ? '' : 's'} ·{' '}
{moneyExact(group.value)}
</Text>
</HStack>
@@ -438,7 +556,9 @@ function GroupDetail({
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' ? (
{/* Never on the waiting queue: nobody is carrying that work, so
"No position reported" states the obvious and reads as a fault. */}
{mode === 'riders' && group.id !== UNASSIGNED ? (
seen ? (
<div className="last-seen">
<MapPin size={14} />
@@ -476,11 +596,31 @@ function GroupDetail({
</VStack>
</Card>
{/* Above the table, the way the Orders page does it: assigning is done
WHILE reading the stops, glancing between the drop addresses and who
is free, not on a screen you have to leave the list for. */}
{assignBar}
<Card padding={0} elevation="low">
<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) => {
// Some can go and some cannot: neither ticked nor
// empty, and the box should say so rather than pick one.
if (node) node.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
@@ -491,38 +631,67 @@ function GroupDetail({
</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>
))}
{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 ? (
/* The click is stopped here so ticking a row does not
also open its drawer — two different intentions on the
same row, and the drawer would bury the bar. */
<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>
<TablePager paged={paged} label="stops" />
</Card>
</VStack>
);