deliveries
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
||||
import { Banknote, Bike, Calculator, Clock, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX, X } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
|
||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
@@ -34,8 +35,12 @@ import {
|
||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||
import { shortAge } from '../posStatus';
|
||||
import { AssignRiderDrawer } from '../AssignRiderDrawer';
|
||||
import { assignability, assignedFrom, awaitingRider, waitingMs } from '../assignDelivery';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import './deliveries.css';
|
||||
|
||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter';
|
||||
|
||||
/**
|
||||
* Sales — app orders, delivery jobs and counter bills.
|
||||
@@ -160,6 +165,70 @@ export function SalesPage() {
|
||||
return { value, charges, unassigned };
|
||||
}, [deliveryRows]);
|
||||
|
||||
/* ── The assignment queue ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Orders with nobody riding them yet.
|
||||
*
|
||||
* Drawn from the ORDERS read, not the deliveries one, and that is the whole
|
||||
* point: an order with no rider has no delivery row, so it appears on the
|
||||
* deliveries page under no filter at all. Before this queue existed there was
|
||||
* nowhere in the console such an order could be seen.
|
||||
*
|
||||
* Not filtered by the status strip. A shop working through its backlog wants
|
||||
* every order still waiting, and "Pending" is not even the status they arrive
|
||||
* with — production orders sit at `created`.
|
||||
*/
|
||||
/**
|
||||
* Which orders the deliveries list already accounts for.
|
||||
*
|
||||
* The order row's own `deliveryid` is the direct answer, but it is only
|
||||
* populated for deliveries created after this backend ships — nothing wrote
|
||||
* `orders.deliveryid` before, and the orders query aliased the column away.
|
||||
* Every delivery already in production sits against an order that still
|
||||
* reads 0, so without this the queue would offer to assign them all again.
|
||||
*/
|
||||
const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
|
||||
|
||||
const queue = useMemo(() => awaitingRider(allOrders, assigned), [allOrders, assigned]);
|
||||
|
||||
const branchOf = useMemo(
|
||||
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
||||
[branches],
|
||||
);
|
||||
|
||||
/**
|
||||
* Only the rows that can actually be sent are selectable.
|
||||
*
|
||||
* Not `queue` — an order whose branch has no delivery region has its own tick
|
||||
* box disabled, and handing every queue id to the selection meant "select
|
||||
* all" ticked it anyway: the box went grey-and-checked and the bar counted
|
||||
* six when only five could go. The drawer caught it and named it, which is
|
||||
* the safety net working, but a count that disagrees with what happens is
|
||||
* how a person stops trusting the ticks.
|
||||
*/
|
||||
const selectable = useMemo(
|
||||
() =>
|
||||
queue
|
||||
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
||||
.map((row) => row.orderheaderid),
|
||||
[queue, branchOf, assigned],
|
||||
);
|
||||
|
||||
const picked = useSelection(selectable);
|
||||
const [isAssigning, setAssigning] = useState(false);
|
||||
|
||||
const pickedOrders = useMemo(
|
||||
() => queue.filter((row) => picked.has(row.orderheaderid)),
|
||||
[queue, picked],
|
||||
);
|
||||
|
||||
/** The oldest wait in the queue — the number that says how bad it is. */
|
||||
const longestWait = useMemo(() => {
|
||||
const waits = queue.map((row) => waitingMs(row)).filter((ms): ms is number => ms !== null);
|
||||
return waits.length > 0 ? Math.max(...waits) : null;
|
||||
}, [queue]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
@@ -194,11 +263,11 @@ export function SalesPage() {
|
||||
label="Online Orders"
|
||||
icon={<ShoppingCart size={14} />}
|
||||
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
|
||||
isActive={tab === 'orders' || tab === 'deliveries'}
|
||||
onClick={() => setTab(tab === 'deliveries' ? 'deliveries' : 'orders')}
|
||||
isActive={tab !== 'counter'}
|
||||
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
|
||||
/>
|
||||
|
||||
{(tab === 'orders' || tab === 'deliveries') ? (
|
||||
{tab !== 'counter' ? (
|
||||
<>
|
||||
<TabButton
|
||||
label="Orders"
|
||||
@@ -207,6 +276,18 @@ export function SalesPage() {
|
||||
isActive={tab === 'orders'}
|
||||
onClick={() => setTab('orders')}
|
||||
/>
|
||||
{/* Between Orders and Deliveries because that is where the work
|
||||
sits: an order becomes a delivery by passing through here.
|
||||
The badge is the count of shoppers currently waiting on
|
||||
somebody in this room, which is why it is the one tab whose
|
||||
number is worth acting on. */}
|
||||
<TabButton
|
||||
label="To assign"
|
||||
icon={<UserX size={14} />}
|
||||
badge={queue.length || undefined}
|
||||
isActive={tab === 'toassign'}
|
||||
onClick={() => setTab('toassign')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Deliveries"
|
||||
icon={<Truck size={14} />}
|
||||
@@ -229,8 +310,9 @@ export function SalesPage() {
|
||||
/>
|
||||
|
||||
{/* The status strip. Only orders and deliveries have a lifecycle — a
|
||||
counter bill is rung and done, so it has nothing to filter by. */}
|
||||
{tab !== 'counter' ? (
|
||||
counter bill is rung and done, so it has nothing to filter by, and
|
||||
the assignment queue is already the answer to one status question. */}
|
||||
{tab === 'orders' || tab === 'deliveries' ? (
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
{STATUS_TABS.map((entry) => (
|
||||
<StatusTab
|
||||
@@ -304,6 +386,72 @@ export function SalesPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── To assign ───────────────────────────────────────────────────── */}
|
||||
{tab === 'toassign' ? (
|
||||
<>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Waiting for a rider"
|
||||
value={count(queue.length)}
|
||||
note="nobody is carrying these yet"
|
||||
tone={queue.length > 0 ? 'warning' : 'success'}
|
||||
icon={<UserX size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Longest wait"
|
||||
value={longestWait === null ? '—' : shortAge(longestWait)}
|
||||
note="since the order was placed"
|
||||
tone={longestWait !== null && longestWait > THIRTY_MINUTES ? 'error' : 'neutral'}
|
||||
icon={<Clock size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Value held up"
|
||||
value={money(queue.reduce((sum, row) => sum + orderValue(row), 0))}
|
||||
note="across the queue"
|
||||
tone="neutral"
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Waiting for a rider"
|
||||
action={
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Oldest first
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<QueueTable
|
||||
rows={queue}
|
||||
isLoading={orders.isLoading}
|
||||
showBranch={selected === null}
|
||||
selection={picked}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
||||
/>
|
||||
{picked.count > 0 ? (
|
||||
<div className="assign-bar">
|
||||
<span className="assign-bar-count">
|
||||
{picked.count} order{picked.count === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
<HStack gap={1} align="center">
|
||||
<BarAction label="Clear" icon={<X size={14} />} onClick={picked.clear} />
|
||||
<Button
|
||||
label="Assign a rider"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Bike size={14} />}
|
||||
onClick={() => setAssigning(true)}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── Deliveries ──────────────────────────────────────────────────── */}
|
||||
{tab === 'deliveries' ? (
|
||||
<>
|
||||
@@ -425,13 +573,225 @@ export function SalesPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isAssigning && pickedOrders.length > 0 ? (
|
||||
<AssignRiderDrawer
|
||||
orders={pickedOrders}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onClose={() => setAssigning(false)}
|
||||
onDone={picked.clear}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* The row is re-read from the live list on every render rather than
|
||||
used as captured, because the drawer can now CHANGE the row it is
|
||||
showing. Marking a job delivered updated the list behind and left the
|
||||
drawer reading "Pending", still offering the buttons — the state was
|
||||
a snapshot taken when it opened. Looking it up keeps it honest, and
|
||||
falls back to the captured row while a refetch is in flight. */}
|
||||
{detail ? (
|
||||
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
|
||||
<OrderDetailDrawer
|
||||
row={freshRow(detail, allOrders, allDeliveries)}
|
||||
kind={detail.kind}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open row, as the list currently has it.
|
||||
*
|
||||
* Keyed on `deliveryid` for a job and `orderheaderid` for an order — both are
|
||||
* stable primary keys, unlike `orderid`, which is not unique in this tenant's
|
||||
* data. Returns the captured row when the list has not caught up yet, so the
|
||||
* drawer never blanks mid-refetch.
|
||||
*/
|
||||
function freshRow(
|
||||
detail: { row: OrderRow | DeliveryRow; kind: RowKind },
|
||||
orders: readonly OrderRow[],
|
||||
deliveries: readonly DeliveryRow[],
|
||||
): OrderRow | DeliveryRow {
|
||||
if (detail.kind === 'delivery') {
|
||||
const id = (detail.row as DeliveryRow).deliveryid;
|
||||
return deliveries.find((row) => row.deliveryid === id) ?? detail.row;
|
||||
}
|
||||
const id = detail.row.orderheaderid;
|
||||
return orders.find((row) => row.orderheaderid === id) ?? detail.row;
|
||||
}
|
||||
|
||||
/** Past this, a shopper is waiting long enough that somebody should notice. */
|
||||
const FIFTEEN_MINUTES = 15 * 60_000;
|
||||
const THIRTY_MINUTES = 30 * 60_000;
|
||||
|
||||
/* ── The assignment queue ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Orders with nobody riding them.
|
||||
*
|
||||
* Its own table rather than a filter on `OrdersTable`, because it answers a
|
||||
* different question and therefore wants different columns. Money and item
|
||||
* counts matter when you are reading a day's trading; when you are deciding who
|
||||
* takes what next, only three things do — where it is going, how long it has
|
||||
* been sitting there, and whether it can be sent at all.
|
||||
*/
|
||||
function QueueTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
selection,
|
||||
branchOf,
|
||||
assigned,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: OrderRow[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
selection: ReturnType<typeof useSelection>;
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
onOpen: (row: OrderRow) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="orders" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Bike size={22} />}
|
||||
title="Every order has a rider"
|
||||
body="Nothing in this date range is waiting to be assigned. New orders appear here as they come in."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select every order in the queue"
|
||||
checked={selection.allChosen}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.someChosen;
|
||||
}}
|
||||
onChange={selection.toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Going to</Th>
|
||||
<Th>Waiting</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const verdict = assignability(row, branchOf(row), assigned);
|
||||
const waited = waitingMs(row);
|
||||
return (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||
{/* The tick stops the click here. Clicking a checkbox to
|
||||
select and getting a drawer instead is the fastest way to
|
||||
make an operator stop trusting the boxes. */}
|
||||
<td
|
||||
className="tick-cell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
borderBottom:
|
||||
'1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
verticalAlign: 'top',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select order ${row.orderid || row.orderheaderid}`}
|
||||
checked={selection.has(row.orderheaderid)}
|
||||
/* An order that cannot be assigned cannot be ticked.
|
||||
Letting it into a batch means the whole array is
|
||||
refused, taking the good orders with it. */
|
||||
disabled={!verdict.canAssign}
|
||||
onChange={() => selection.toggle(row.orderheaderid)}
|
||||
/>
|
||||
</td>
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.orderdate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td>
|
||||
<Primary>{branchLabel(row.locationname)}</Primary>
|
||||
<Secondary>{row.applocation}</Secondary>
|
||||
</Td>
|
||||
) : null}
|
||||
<Td>
|
||||
<Primary w={200}>{row.deliverycustomer || '—'}</Primary>
|
||||
<Secondary w={200}>{row.deliveryaddress || row.deliverysuburb}</Secondary>
|
||||
</Td>
|
||||
<Td>
|
||||
{waited === null ? (
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||
) : (
|
||||
<span className="waited" data-tone={waitTone(waited)}>
|
||||
<Clock size={11} />
|
||||
{shortAge(waited)}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td isStrong>
|
||||
<Mono>{moneyExact(orderValue(row))}</Mono>
|
||||
</Td>
|
||||
<Td>
|
||||
<Chip
|
||||
label={row.orderstatus}
|
||||
color={statusColor(ORDER_STATUS, row.orderstatus)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
{/* The reason is on the row rather than only in the drawer.
|
||||
A branch with no delivery region is a configuration
|
||||
problem, and the person who can see it is the one
|
||||
looking at this list. */}
|
||||
{verdict.canAssign ? (
|
||||
<ViewHint />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: '#8a5a00',
|
||||
display: 'block',
|
||||
maxWidth: 190,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{verdict.reason}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function waitTone(ms: number): 'neutral' | 'warning' | 'critical' {
|
||||
if (ms >= THIRTY_MINUTES) return 'critical';
|
||||
if (ms >= FIFTEEN_MINUTES) return 'warning';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/* ── Orders table ────────────────────────────────────────────────────────── */
|
||||
|
||||
function OrdersTable({
|
||||
|
||||
Reference in New Issue
Block a user