deliveries

This commit is contained in:
2026-09-03 19:13:04 +05:30
parent 25073659d8
commit 743aa93e20
11 changed files with 1787 additions and 10 deletions

View File

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