pagenation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user