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