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

@@ -10,6 +10,29 @@ import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
* these three change it.
*/
/**
* The rider has no device registered.
*
* Its own error type because the remedy differs from a transport failure: the
* rider must open the app and sign in, not retry. Collapsed into a generic
* "notification failed", an operator assumes the network is at fault and tries
* again forever.
*/
export class RiderNotReachableError extends Error {
constructor(message = 'This rider has no device registered, so they were not told.') {
super(message);
this.name = 'RiderNotReachableError';
}
}
/** What the rider is told. Kept together so the wording stays consistent. */
export const RIDER_MESSAGE = {
assigned: (count: number) =>
count === 1
? 'An order has been assigned to you. Kindly accept and process the delivery.'
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
} as const;
export interface RiderQuery {
/**
* The delivery region. This is the scope that works.
@@ -51,6 +74,29 @@ export const deliveriesApi = {
assign: (rows: DeliveryDraft[]) =>
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
/**
* Tell a rider they have work.
*
* Deliberately NOT chained into `assign` — the deliveries are committed by
* the time this runs, so a failed push must not read as a failed assignment.
* Callers fire it afterwards and report the two outcomes separately: a rider
* who was never told has work sitting unseen, which the operator needs to
* know without being told the assignment failed.
*
* The backend holds the Firebase credentials; this only relays.
*/
notify: (token: string, body: string) => {
// Checked here rather than at the server: posting an empty token returns
// FCM's "exactly one of token, topic or condition must be specified",
// which reads as a server fault rather than as a rider who has never
// opened the app. Verified against the live endpoint.
if (!token.trim()) throw new RiderNotReachableError();
return api.post<unknown>(`${WEB}/utils/notifyuser`, {
token: token.trim(),
notification: { title: 'NearleXpress', body },
});
},
/**
* Move a delivery along its ladder, or hand it to a different rider.
*

View File

@@ -518,6 +518,8 @@ export interface RiderInfo {
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
/** The device to push to. Empty for a rider who has never opened the app. */
userfcmtoken?: string;
shiftid?: number;
/** Shift window, as `HH:MM:SS`. */
starttime?: string;

View File

@@ -0,0 +1,156 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
import { UserCheck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
RIDER_MESSAGE,
RiderNotReachableError,
deliveriesApi,
} from '@/api/deliveries';
import type { OrderRow, TenantLocation } from '@/api/types';
import { useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
/**
* Assigning riders, from the orders table itself.
*
* A bar above the list rather than a page or a drawer of its own, which is what
* the old console did and the right shape for the job: assigning is something
* you do WHILE reading the day's orders, glancing between the drop addresses
* and who is free. A separate screen makes you leave the list to act on it and
* come back to check.
*
* It appears when rows are ticked and stays afterwards to report what happened,
* because the result is the only record — `createdeliveries` answers with a
* message and no ids.
*/
export interface AssignBarProps {
/** The ticked orders. */
orders: OrderRow[];
branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>;
onClear: () => void;
onDone: () => void;
}
export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) {
const client = useQueryClient();
const [riderId, setRiderId] = useState('');
const [outcome, setOutcome] = useState<string | null>(null);
// Every order in a batch shares a region in practice — they are one branch's
// orders — so the first one carrying a region decides which fleet to ask for.
const region =
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
const riders = useRiders(region || undefined);
const fleet = riders.data ?? [];
const rider = fleet.find((entry) => String(entry.userid) === riderId);
const send = useMutation({
mutationFn: async () => {
if (!rider) throw new Error('Pick a rider first');
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned);
if (drafts.length === 0) {
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
}
await deliveriesApi.assign(drafts);
return { count: drafts.length, skipped: skipped.length };
},
onSuccess: async ({ count, skipped }) => {
const who = riderName(rider!);
const base =
skipped === 0
? `${count} order${count === 1 ? '' : 's'} assigned to ${who}`
: `${count} assigned to ${who} · ${skipped} could not be and are still waiting`;
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
onDone();
/*
* The push is fired after the write and reported separately.
*
* The deliveries exist either way, so a failed notification must not read
* as a failed assignment — but it must still be visible, because a rider
* who was never told has work sitting unseen. This is the old console's
* contract and it is the right one.
*/
setOutcome(`${base} · telling them…`);
try {
await deliveriesApi.notify(rider!.userfcmtoken ?? '', RIDER_MESSAGE.assigned(count));
setOutcome(`${base} · rider notified`);
} catch (error) {
setOutcome(
`${base} · NOT notified — ${
error instanceof RiderNotReachableError
? 'this rider has no device registered'
: 'the push failed, tell them another way'
}`,
);
}
},
onError: (error) => setOutcome(errorMessage(error)),
});
const options = fleet.map((entry) => ({
value: String(entry.userid),
label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
}));
return (
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
<span className="assign-bar-count">
<UserCheck size={15} />
{orders.length} selected
</span>
<div className="assign-bar-picker">
<Selector
label="Choose a rider"
isLabelHidden
size="sm"
value={riderId}
onChange={(value) => {
setRiderId(String(value));
setOutcome(null);
}}
options={options}
isDisabled={orders.length === 0}
/* The empty-list wording carries the actual information — which of
the three situations you are in — so it lives in the placeholder
rather than being flattened to "Select…". "No riders on duty" is a
shift that has not started; the region case is a branch somebody
has to configure. Neither is a fault in the orders. */
placeholder={
region === 0
? 'No delivery region on this branch'
: riders.isLoading
? 'Loading riders…'
: options.length > 0
? 'Select a rider…'
: 'No rider has clocked on today'
}
/>
</div>
<Button
label={send.isPending ? 'Assigning…' : 'Assign rider'}
variant="primary"
size="sm"
icon={<UserCheck size={14} />}
isDisabled={!rider || orders.length === 0 || send.isPending}
onClick={() => send.mutate()}
/>
<Button label="Clear" variant="ghost" size="sm" onClick={onClear} />
{outcome ? (
<span className="assign-bar-outcome" role="status">
{outcome}
</span>
) : null}
</div>
);
}

View File

@@ -1,291 +0,0 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Bike, Check, MapPin, Phone } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { deliveriesApi } from '@/api/deliveries';
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import {
assignability,
buildDeliveries,
riderName,
riderVehicle,
waitingMs,
} from './assignDelivery';
import { money } from './format';
import { shortAge } from './posStatus';
import { orderValue } from './orderStatus';
/**
* Handing orders to a rider.
*
* One drawer for one order and for twenty, because the call underneath is the
* same either way: `createdeliveries` takes an array and writes it in one
* transaction. Building a single-order path as well would be two code paths
* for one endpoint, and the second one is always the one that rots.
*
* ── What this screen is careful about ───────────────────────────────────────
*
* Assigning is an INSERT, not a status change, and the row it inserts is what
* every later read joins on. An order whose branch has no delivery region set
* produces a job that saves cleanly and then cannot be seen by anybody — so
* those are held back here, named, and left in the queue rather than sent and
* quietly lost. `assignDelivery.ts` holds the rules; this shows their answers.
*/
export interface AssignRiderDrawerProps {
orders: OrderRow[];
/** The branch each order belongs to, for the pickup end and the region. */
branchOf: (row: OrderRow) => TenantLocation | undefined;
/**
* Orders the deliveries list already accounts for. Covers the rows assigned
* before the backend started writing `orders.deliveryid`.
*/
assigned?: ReadonlySet<number>;
onClose: () => void;
/** Called once at least one job was created, so the caller can clear its ticks. */
onDone: () => void;
}
export function AssignRiderDrawer({
orders,
branchOf,
assigned,
onClose,
onDone,
}: AssignRiderDrawerProps) {
const client = useQueryClient();
const [chosen, setChosen] = useState<number | null>(null);
const [problem, setProblem] = useState<string | null>(null);
// Every order in a batch shares a region in practice — they are the same
// branch's orders — so the first one that has a region decides which fleet
// to ask for.
const region =
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
const riders = useRiders(region || undefined);
const fleet = riders.data ?? [];
const ready = orders.filter((row) => assignability(row, branchOf(row), assigned).canAssign);
const held = orders
.map((row) => ({ row, reason: assignability(row, branchOf(row), assigned).reason }))
.filter((entry) => entry.reason !== '');
const rider = fleet.find((entry) => entry.userid === chosen);
const send = useMutation({
mutationFn: () => {
if (!rider) throw new Error('Pick a rider first');
const { drafts } = buildDeliveries(ready, rider, branchOf, new Date(), assigned);
if (drafts.length === 0) throw new Error('None of these orders can be assigned');
return deliveriesApi.assign(drafts);
},
onSuccess: async () => {
// The response carries a message and no ids, so both lists are refetched
// rather than patched: the orders now have a deliveryid and the
// deliveries page has rows it did not have a moment ago.
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
onDone();
onClose();
},
onError: (error) => setProblem(errorMessage(error)),
});
return (
<Drawer
title={ready.length === 1 ? 'Assign a rider' : `Assign ${ready.length} orders`}
subtitle={
ready.length === 1
? ready[0]?.orderid || `#${ready[0]?.orderheaderid}`
: 'They all go to the same rider'
}
width={480}
onClose={onClose}
>
<VStack gap={3}>
{/* ── What is being handed over ───────────────────────────────── */}
<VStack gap={1}>
<Text type="label" size="xsm" color="secondary">
{ready.length === 1 ? 'This order' : `These ${ready.length} orders`}
</Text>
<div className="assign-list">
{ready.map((row) => (
<div key={row.orderheaderid} className="assign-row">
<div className="assign-row-main">
<strong>{row.deliverycustomer || 'Customer'}</strong>
<span>{row.deliveryaddress || row.deliverysuburb || 'No address'}</span>
</div>
<div className="assign-row-side">
<strong>{money(orderValue(row))}</strong>
<span>{ageLabel(row)}</span>
</div>
</div>
))}
</div>
</VStack>
{/* Orders that came in with the selection but cannot be sent. Named
rather than silently dropped — the operator has to know one is
still sitting there, and why. */}
{held.length > 0 ? (
<div className="assign-held">
<HStack gap={1} align="start">
<AlertTriangle size={15} />
<VStack gap={0.5}>
<Text type="body" size="sm">
{held.length === 1
? 'One order is not going with this batch'
: `${held.length} orders are not going with this batch`}
</Text>
{held.map(({ row, reason }) => (
<Text key={row.orderheaderid} type="body" size="xsm" color="secondary">
{row.orderid || `#${row.orderheaderid}`} — {reason}
</Text>
))}
</VStack>
</HStack>
</div>
) : null}
{/* ── The fleet ───────────────────────────────────────────────── */}
<VStack gap={1}>
<Text type="label" size="xsm" color="secondary">
Riders on duty
</Text>
<RiderPicker
riders={fleet}
isLoading={riders.isLoading}
hasRegion={region !== 0}
chosen={chosen}
onChoose={(id) => {
setChosen(id);
setProblem(null);
}}
/>
</VStack>
{problem ? (
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<Button
label={send.isPending ? 'Assigning…' : 'Assign'}
variant="primary"
size="sm"
icon={<Bike size={14} />}
isDisabled={!rider || ready.length === 0 || send.isPending}
onClick={() => send.mutate()}
/>
</HStack>
<Text type="body" size="xsm" color="secondary">
The rider sees the job in their app straight away, and the order moves onto the
deliveries list.
</Text>
</VStack>
</Drawer>
);
}
/**
* Which rider takes it.
*
* The three empty states are different problems with different fixes, so they
* say different things. "Nobody has clocked on" is a shift that has not
* started; "no delivery region" is a branch someone has to configure; and a
* fleet of one is neither — it just looks like a bug if the list is silent
* about it.
*/
function RiderPicker({
riders,
isLoading,
hasRegion,
chosen,
onChoose,
}: {
riders: RiderInfo[];
isLoading: boolean;
hasRegion: boolean;
chosen: number | null;
onChoose: (userid: number) => void;
}) {
if (!hasRegion) {
return (
<div className="assign-empty">
<MapPin size={16} />
<Text type="body" size="sm" color="secondary">
These orders have no delivery region, so there is no fleet to ask. Set the region on the
branch first.
</Text>
</div>
);
}
if (isLoading) {
return (
<div className="assign-empty">
<Text type="body" size="sm" color="secondary">
Finding riders…
</Text>
</div>
);
}
if (riders.length === 0) {
return (
<div className="assign-empty">
<Bike size={16} />
<Text type="body" size="sm" color="secondary">
No rider has clocked on today. The list fills as riders start their shift — nothing is
wrong with the orders.
</Text>
</div>
);
}
return (
<div className="rider-list">
{riders.map((rider) => {
const isChosen = rider.userid === chosen;
return (
<button
key={rider.userid}
type="button"
className="rider-option"
data-chosen={isChosen}
aria-pressed={isChosen}
onClick={() => onChoose(rider.userid)}
>
<span className="rider-mark">{isChosen ? <Check size={13} /> : <Bike size={13} />}</span>
<span className="rider-text">
<strong>{riderName(rider)}</strong>
<span>{riderVehicle(rider) || 'No vehicle recorded'}</span>
</span>
{rider.contactno ? (
<span className="rider-contact">
<Phone size={11} />
{rider.contactno}
</span>
) : null}
</button>
);
})}
</div>
);
}
/** How long this one has been waiting. Blank rather than "0m" when undatable. */
function ageLabel(row: OrderRow): string {
const waited = waitingMs(row);
return waited === null ? '' : `waiting ${shortAge(waited)}`;
}

View File

@@ -223,13 +223,44 @@ test('the job says what is being carried, which the old rows left at zero', () =
assert.equal(draft.deliveryamt, 80);
});
test('times are local wall-clock, not ISO instants', () => {
const draft = buildDelivery(order(), rider, branch, NOW);
assert.equal(draft.assigntime, '2026-09-03 17:30:00');
assert.equal(draft.deliverydate, '2026-09-03');
test('assigntime is local wall-clock, not an ISO instant', () => {
assert.equal(buildDelivery(order(), rider, branch, NOW).assigntime, '2026-09-03 17:30:00');
assert.equal(stampNow(new Date(2026, 0, 5, 9, 7, 3)), '2026-01-05 09:07:03');
});
test('the job takes the date it is FOR, walking the order row for it', () => {
/*
`getdeliveries` filters on deliverydate, so a job written without one is
excluded from every dated view, and a job stamped today when it is scheduled
for tomorrow lands in the wrong day's window. An order carries its slot as
`deliverytime` — there is no `deliverydate` on an order row — hence the chain,
which is the old console's and is the old console's for this reason.
*/
assert.equal(
buildDelivery(order({ deliverydate: '2026-09-05' }), rider, branch, NOW).deliverydate,
'2026-09-05',
);
assert.equal(
buildDelivery(order({ deliverytime: '2026-09-06 11:00:00' }), rider, branch, NOW).deliverydate,
'2026-09-06 11:00:00',
);
// Neither present: the order's own date.
assert.equal(
buildDelivery(order(), rider, branch, NOW).deliverydate,
'2026-09-03T16:16:39+05:30',
);
});
test('a job is never written date-less, because a date-less job is invisible', () => {
const draft = buildDelivery(
order({ deliverydate: '', deliverytime: '', orderdate: '' }),
rider,
branch,
NOW,
);
assert.equal(draft.deliverydate, '2026-09-03 17:30:00');
});
test('the branch supplies a pickup end the order does not carry', () => {
const thin = buildDelivery(
order({ pickupcustomer: '', pickupaddress: '', pickupcontactno: '' }),

View File

@@ -176,18 +176,15 @@ export function stampNow(now = new Date()): string {
);
}
/** Just the date half, for `deliverydate`. */
export function dateOf(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
/**
* One row of the `createdeliveries` array.
*
* `Pickupaddress` is capitalised. That is not a typo here — it is the JSON tag
* on `models.Deliveries`, alone among fifty lowercase ones, and sending
* `pickupaddress` instead means the address is simply dropped.
* `Pickupaddress` is capitalised because that is the JSON tag on
* `models.Deliveries`, alone among fifty lowercase ones. Matching it exactly is
* a courtesy rather than a requirement: Go's decoder matches field names
* case-insensitively, so the old console's lowercase `pickupaddress` landed
* fine — verified. Spelled as the model spells it so a reader grepping the Go
* struct finds this.
*/
export interface DeliveryDraft {
orderheaderid: number;
@@ -261,9 +258,18 @@ export function buildDelivery(
userid: rider.userid,
orderstatus: DELIVERY_CREATED_STATUS,
// The date the job is for, and the moment it was handed out. Both are
// written by us: the backend stamps neither on create.
deliverydate: dateOf(now),
/*
* The date the job is FOR, not today.
*
* An order row carries its scheduled slot as `deliverytime` — there is no
* `deliverydate` on an order — so it is copied through, with the order date
* and finally the assign stamp behind it. The fallback chain is the old
* console's and the reason is its reason: `getdeliveries` filters on
* `deliverydate`, so a delivery written without one is excluded from every
* dated view, and a job stamped today when it is scheduled for tomorrow
* shows up in the wrong day's window.
*/
deliverydate: row.deliverydate || row.deliverytime || row.orderdate || at,
assigntime: at,
// What the rider is carrying, so the deliveries page can say so.

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>

View File

@@ -1,176 +1,65 @@
/* ══ Assigning riders ══════════════════════════════════════════════════════
Built on the site's tokens and on the Console's .panel/.pill vocabulary, so
the drawer and the queue read as the same product as everything around them.
Only what is particular to assignment is defined here. */
Assignment lives in the Orders table, not on a page of its own — so this is
a tick column and a toolbar, and nothing more. Built on the site's tokens so
the bar reads as part of the same table it sits above. */
/* ── The orders in the drawer ───────────────────────────────────────────── */
/* A list, not a table: this is a confirmation of what is about to be handed
over, and the columns of the queue behind it would only be repeated here. */
.assign-list {
display: flex;
flex-direction: column;
border: 1px solid var(--color-line);
border-radius: 10px;
overflow: hidden;
max-height: 240px;
overflow-y: auto;
}
.assign-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 10px 12px;
border-bottom: 1px solid var(--color-line);
}
.assign-row:last-child { border-bottom: none; }
.assign-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.assign-row-main strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
.assign-row-main span {
font: 400 11.5px/1.35 var(--font-sans);
color: var(--color-ink-3);
/* Addresses run long and the money column must not be pushed off screen. */
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.assign-row-side { display: flex; flex-direction: column; gap: 2px; text-align: right; }
.assign-row-side strong {
font: 600 13px/1.3 var(--font-sans);
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
}
.assign-row-side span { font: 400 11px/1.3 var(--font-sans); color: var(--color-ink-3); white-space: nowrap; }
/* ── Orders held back ───────────────────────────────────────────────────── */
/* Amber, not red. Nothing has failed — these were never sent, and the operator
still has them. Red would say something was lost. */
.assign-held {
padding: 11px 13px;
border: 1px solid #f2d9a8;
border-radius: 10px;
background: #fdf6e8;
color: #8a5a00;
}
.assign-held svg { flex: none; margin-top: 2px; }
/* ── Empty states ───────────────────────────────────────────────────────── */
.assign-empty {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border: 1px dashed var(--color-line);
border-radius: 10px;
background: var(--color-surface-subtle);
color: var(--color-ink-3);
}
.assign-empty svg { flex: none; }
/* ── The rider picker ───────────────────────────────────────────────────── */
/* Buttons rather than a <select>: there are rarely more than a handful of
riders on duty, and the vehicle and phone number are half of how an operator
picks between two names they only half recognise. A dropdown hides both. */
.rider-list { display: flex; flex-direction: column; gap: 6px; }
.rider-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface);
/* ── The tick column ────────────────────────────────────────────────────── */
/* First column of the table, which is where the old console put it and where a
person looks for it. Fixed width so the header box and the row boxes line up
whatever the rest of the row does. */
.tick-cell {
width: 38px;
padding-left: 12px;
text-align: left;
cursor: pointer;
}
.rider-option:hover { border-color: var(--color-line-strong, var(--color-ink-4)); }
.rider-option[data-chosen='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.rider-option:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.rider-mark {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--color-surface-sunken);
color: var(--color-ink-3);
}
.rider-option[data-chosen='true'] .rider-mark {
background: var(--color-brand);
color: #fff;
}
.rider-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.rider-text strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
.rider-text span { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
.rider-contact {
display: inline-flex;
align-items: center;
gap: 4px;
font: 500 11.5px/1 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* ── The waiting badge on a queue row ───────────────────────────────────── */
/* How long an order has sat unassigned is the one fact that decides what an
operator does next, so it is not left as a timestamp to subtract. Amber past
fifteen minutes, red past thirty — a shopper who ordered half an hour ago and
has no rider is the reason to open this page. */
.waited {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
font: 500 11.5px/1.5 var(--font-sans);
font-variant-numeric: tabular-nums;
white-space: nowrap;
background: var(--color-surface-sunken);
color: var(--color-ink-2);
}
.waited[data-tone='warning'] { background: #fdf6e8; color: #8a5a00; }
.waited[data-tone='critical'] { background: #fdefee; color: #b3261e; }
/* ── The queue's action bar ─────────────────────────────────────────────── */
/* Sticks to the bottom while the operator works down a long list, so the
button is never a scroll away from the ticks that armed it. */
/* ── The assign bar ─────────────────────────────────────────────────────── */
/* Above the table, appearing when rows are ticked and staying afterwards to
report what happened — `createdeliveries` answers with a message and no ids,
so this line is the only record of the result. */
.assign-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--color-brand);
border-radius: 12px;
background: var(--color-brand-tint);
box-shadow: 0 -6px 20px -12px rgb(15 23 42 / 0.25);
}
.assign-bar-count {
display: inline-flex;
align-items: center;
gap: 6px;
font: 600 13px/1.3 var(--font-sans);
color: var(--color-brand);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* The tick column. Its own cell so clicking a box never opens the row. */
.tick-cell { width: 34px; padding-left: 12px; }
/* Wide enough for "Meera Raj · Scooter · TN 37 AB 4410" without the name being
the part that gets clipped, capped so it does not push the buttons off. */
.assign-bar-picker {
min-width: 220px;
max-width: 300px;
flex: 1 1 220px;
}
/* Pushed to the far end so the result reads as an outcome rather than as
another control. Wraps to its own line on a narrow window rather than
squeezing the picker. */
.assign-bar-outcome {
margin-left: auto;
font: 500 12px/1.4 var(--font-sans);
color: var(--color-ink-2);
max-width: 100%;
}
/* ── Recording progress by hand ─────────────────────────────────────────── */
/* Three plain buttons rather than a dropdown-and-save. Each is one decision
the operator has already made before opening the drawer; a two-step control
would add a confirmation to a correction that is itself reversible. */
/* Three plain buttons rather than a dropdown-and-save. Each is one decision the
operator has already made before opening the drawer; a two-step control would
add a confirmation to a correction that is itself reversible. */
.progress-moves { display: flex; gap: 6px; flex-wrap: wrap; }
.progress-move {
height: 30px;