assign option

This commit is contained in:
2026-09-04 11:31:07 +05:30
parent 743aa93e20
commit 416c50755b
8 changed files with 450 additions and 785 deletions

View File

@@ -1,16 +1,15 @@
import { useMemo, useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
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, Clock, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX, X } from 'lucide-react';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
useDeliveries,
useOrders,
@@ -35,12 +34,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 { AssignBar } from '../AssignBar';
import { assignability, assignedFrom } from '../assignDelivery';
import { useSelection } from '@/components/useSelection';
import './deliveries.css';
type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter';
type Tab = 'orders' | 'deliveries' | 'counter';
/**
* Sales — app orders, delivery jobs and counter bills.
@@ -165,70 +164,49 @@ export function SalesPage() {
return { value, charges, unassigned };
}, [deliveryRows]);
/* ── The assignment queue ─────────────────────────────────────────────── */
/* ── Assigning riders, from the orders list ───────────────────────────── */
/**
* 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.
* The order row's own `deliveryid` is the direct answer, and it is only
* populated for deliveries created since the backend started writing it —
* nothing wrote `orders.deliveryid` before, and the orders query aliased the
* column away. Every delivery that predates that fix sits against an order
* still reading 0, so without this the list would offer to assign the whole
* of production's delivery history a second time.
*/
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.
* The orders a rider can be put on, out of the ones currently listed.
*
* 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.
* The selection is built from these rather than from every visible row, so
* "select all" only ever ticks what can actually be sent. Handing it every
* row instead 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.
*/
const selectable = useMemo(
() =>
queue
orderRows
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
.map((row) => row.orderheaderid),
[queue, branchOf, assigned],
[orderRows, branchOf, assigned],
);
const picked = useSelection(selectable);
const [isAssigning, setAssigning] = useState(false);
const pickedOrders = useMemo(
() => queue.filter((row) => picked.has(row.orderheaderid)),
[queue, picked],
() => orderRows.filter((row) => picked.has(row.orderheaderid)),
[orderRows, 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
@@ -276,18 +254,6 @@ 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} />}
@@ -309,23 +275,6 @@ 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, 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
key={entry.key}
label={entry.label}
n={tabCounts[entry.key]}
isActive={status === entry.key}
onClick={() => setStatus(entry.key)}
/>
))}
</HStack>
) : null}
{/* ── Orders ──────────────────────────────────────────────────────── */}
{tab === 'orders' ? (
<>
@@ -361,14 +310,35 @@ export function SalesPage() {
</div>
<VStack gap={1.5}>
<SectionHeader
title="Orders"
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
<FilterRow
status={status}
counts={tabCounts}
onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
}
/>
{/* Above the table, not below it and not in a drawer.
Assigning is something you do WHILE reading the day's orders —
glancing between the drop addresses and who is free — so the
control belongs beside the list rather than on a screen you have
to leave the list for. This is where the old console put it. */}
{picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null}
<OrdersTable
rows={orderRows}
isLoading={orders.isLoading}
showBranch={selected === null}
selection={picked}
branchOf={branchOf}
assigned={assigned}
onOpen={(row) => setDetail({ row, kind: 'order' })}
/>
{orderRows.length > 0 ? (
@@ -386,71 +356,6 @@ 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' ? (
@@ -487,9 +392,13 @@ export function SalesPage() {
</div>
<VStack gap={1.5}>
<SectionHeader
title="Deliveries"
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
<FilterRow
status={status}
counts={tabCounts}
onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
}
/>
<DeliveriesTable
rows={deliveryRows}
@@ -549,12 +458,11 @@ export function SalesPage() {
</div>
<VStack gap={1.5}>
<SectionHeader
title="Counter bills"
action={
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
}
/>
{/* No status strip here: a counter bill is rung and done, so it
has no lifecycle to filter by. The search keeps the row. */}
<HStack justify="end" wrap="wrap">
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
</HStack>
<BillsTable
rows={billRows}
isLoading={billPages.some((page) => page.isLoading)}
@@ -573,15 +481,6 @@ 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
@@ -620,23 +519,51 @@ function freshRow(
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.
* The status filters and the search, on one line above the table.
*
* 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.
* This row replaces the "Orders" / "Deliveries" headings that used to sit here.
* They named the table the tab had already named — two words of chrome between
* the numbers and the rows — while the filters sat above the KPI cards, so the
* control and the list it narrowed were separated by four cards.
*
* Below the cards is where the filters belong: the cards summarise everything
* in view, the filters decide what is in view, and the table shows it. Reading
* downward now follows that order.
*/
function QueueTable({
function FilterRow({
status,
counts,
onStatus,
search,
}: {
status: StatusKey;
counts: Record<StatusKey, number>;
onStatus: (key: StatusKey) => void;
search: React.ReactNode;
}) {
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={0.5} wrap="wrap">
{STATUS_TABS.map((entry) => (
<StatusTab
key={entry.key}
label={entry.label}
n={counts[entry.key]}
isActive={status === entry.key}
onClick={() => onStatus(entry.key)}
/>
))}
</HStack>
{search}
</HStack>
);
}
/* ── Orders table ────────────────────────────────────────────────────────── */
function OrdersTable({
rows,
isLoading,
showBranch,
@@ -652,158 +579,6 @@ function QueueTable({
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({
rows,
isLoading,
showBranch,
onOpen,
}: {
rows: OrderRow[];
isLoading: boolean;
showBranch: boolean;
onOpen: (row: OrderRow) => void;
}) {
if (isLoading) return <Loading what="orders" />;
if (rows.length === 0) {
@@ -822,6 +597,17 @@ function OrdersTable({
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
{/* The tick column, first — the old console's position, and the
one place a person looks for it. */}
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
<CheckboxInput
label="Select every order that can be assigned"
isLabelHidden
size="sm"
value={selection.allChosen ? true : selection.someChosen ? 'indeterminate' : false}
onChange={selection.toggleAll}
/>
</th>
<Th>#</Th>
<Th>Order</Th>
{showBranch ? <Th>Branch</Th> : null}
@@ -840,8 +626,36 @@ function OrdersTable({
{rows.map((row, index) => {
const cod = row.collectionamt ?? 0;
const amount = orderValue(row);
const verdict = assignability(row, branchOf(row), assigned);
const isPicked = selection.has(row.orderheaderid);
return (
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
<Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}>
{/* The tick swallows its own click. Reaching for a checkbox
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: 11,
}}
>
<CheckboxInput
label={`Select order ${row.orderid || row.orderheaderid}`}
isLabelHidden
size="sm"
value={isPicked}
/* Disabled rather than absent, so the column stays a
column and the row explains itself on hover instead of
leaving a silent gap. */
isDisabled={!verdict.canAssign}
disabledMessage={verdict.reason}
onChange={() => selection.toggle(row.orderheaderid)}
/>
</td>
<Td isMuted>
<Mono>{index + 1}</Mono>
</Td>
@@ -1512,7 +1326,16 @@ const ViewHint = () => (
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-brand)' }}>View →</span>
);
function Row({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
function Row({
children,
onClick,
isPicked,
}: {
children: React.ReactNode;
onClick: () => void;
/** Ticked rows take a brand wash, so a selection is visible while scrolling. */
isPicked?: boolean;
}) {
return (
<tr
onClick={onClick}
@@ -1524,7 +1347,10 @@ function Row({ children, onClick }: { children: React.ReactNode; onClick: () =>
onClick();
}
}}
style={{ cursor: 'pointer' }}
style={{
cursor: 'pointer',
...(isPicked ? { background: 'var(--color-brand-tint)' } : {}),
}}
>
{children}
</tr>