diff --git a/src/api/deliveries.ts b/src/api/deliveries.ts index 9a71f4f..cb5ccf3 100644 --- a/src/api/deliveries.ts +++ b/src/api/deliveries.ts @@ -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(`${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(`${WEB}/utils/notifyuser`, { + token: token.trim(), + notification: { title: 'NearleXpress', body }, + }); + }, + /** * Move a delivery along its ladder, or hand it to a different rider. * diff --git a/src/api/types.ts b/src/api/types.ts index fe2b97c..d30cbf8 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; diff --git a/src/features/store-admin/AssignBar.tsx b/src/features/store-admin/AssignBar.tsx new file mode 100644 index 0000000..51e52ab --- /dev/null +++ b/src/features/store-admin/AssignBar.tsx @@ -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; + onClear: () => void; + onDone: () => void; +} + +export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) { + const client = useQueryClient(); + const [riderId, setRiderId] = useState(''); + const [outcome, setOutcome] = useState(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 ( +
+ + + {orders.length} selected + + +
+ { + 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' + } + /> +
+ +
+ ); +} diff --git a/src/features/store-admin/AssignRiderDrawer.tsx b/src/features/store-admin/AssignRiderDrawer.tsx deleted file mode 100644 index 6403ace..0000000 --- a/src/features/store-admin/AssignRiderDrawer.tsx +++ /dev/null @@ -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; - 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(null); - const [problem, setProblem] = useState(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 ( - - - {/* ── What is being handed over ───────────────────────────────── */} - - - {ready.length === 1 ? 'This order' : `These ${ready.length} orders`} - -
- {ready.map((row) => ( -
-
- {row.deliverycustomer || 'Customer'} - {row.deliveryaddress || row.deliverysuburb || 'No address'} -
-
- {money(orderValue(row))} - {ageLabel(row)} -
-
- ))} -
-
- - {/* 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 ? ( -
- - - - - {held.length === 1 - ? 'One order is not going with this batch' - : `${held.length} orders are not going with this batch`} - - {held.map(({ row, reason }) => ( - - {row.orderid || `#${row.orderheaderid}`} — {reason} - - ))} - - -
- ) : null} - - {/* ── The fleet ───────────────────────────────────────────────── */} - - - Riders on duty - - { - setChosen(id); - setProblem(null); - }} - /> - - - {problem ? ( - - {problem} - - ) : null} - - - - ); - })} - - ); -} - -/** 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)}`; -} diff --git a/src/features/store-admin/assignDelivery.test.ts b/src/features/store-admin/assignDelivery.test.ts index 5f679e5..cc37762 100644 --- a/src/features/store-admin/assignDelivery.test.ts +++ b/src/features/store-admin/assignDelivery.test.ts @@ -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: '' }), diff --git a/src/features/store-admin/assignDelivery.ts b/src/features/store-admin/assignDelivery.ts index 0654229..7d509bb 100644 --- a/src/features/store-admin/assignDelivery.ts +++ b/src/features/store-admin/assignDelivery.ts @@ -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. diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index abc263a..a9a07b8 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -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 ( 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. */} - } - badge={queue.length || undefined} - isActive={tab === 'toassign'} - onClick={() => setTab('toassign')} - /> } @@ -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' ? ( - - {STATUS_TABS.map((entry) => ( - setStatus(entry.key)} - /> - ))} - - ) : null} - {/* ── Orders ──────────────────────────────────────────────────────── */} {tab === 'orders' ? ( <> @@ -361,14 +310,35 @@ export function SalesPage() { - } + + } /> + {/* 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 ? ( + + ) : null} setDetail({ row, kind: 'order' })} /> {orderRows.length > 0 ? ( @@ -386,71 +356,6 @@ export function SalesPage() { ) : null} - {/* ── To assign ───────────────────────────────────────────────────── */} - {tab === 'toassign' ? ( - <> -
- 0 ? 'warning' : 'success'} - icon={} - /> - THIRTY_MINUTES ? 'error' : 'neutral'} - icon={} - /> - sum + orderValue(row), 0))} - note="across the queue" - tone="neutral" - icon={} - /> -
- - - - Oldest first - - } - /> - setDetail({ row, kind: 'order' })} - /> - {picked.count > 0 ? ( -
- - {picked.count} order{picked.count === 1 ? '' : 's'} selected - - - } onClick={picked.clear} /> -
- ) : null} -
- - ) : null} {/* ── Deliveries ──────────────────────────────────────────────────── */} {tab === 'deliveries' ? ( @@ -487,9 +392,13 @@ export function SalesPage() { - } + + } /> - - } - /> + {/* No status strip here: a counter bill is rung and done, so it + has no lifecycle to filter by. The search keeps the row. */} + + + page.isLoading)} @@ -573,15 +481,6 @@ export function SalesPage() { /> ) : null} - {isAssigning && pickedOrders.length > 0 ? ( - 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; + onStatus: (key: StatusKey) => void; + search: React.ReactNode; +}) { + return ( + + + {STATUS_TABS.map((entry) => ( + onStatus(entry.key)} + /> + ))} + + {search} + + ); +} + +/* ── Orders table ────────────────────────────────────────────────────────── */ + +function OrdersTable({ rows, isLoading, showBranch, @@ -652,158 +579,6 @@ function QueueTable({ branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; onOpen: (row: OrderRow) => void; -}) { - if (isLoading) return ; - if (rows.length === 0) { - return ( - } - 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 ( - -
- - - - - - {showBranch ? : null} - - - - - - - - {rows.map((row) => { - const verdict = assignability(row, branchOf(row), assigned); - const waited = waitingMs(row); - return ( - 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. */} - - - {showBranch ? ( - - ) : null} - - - - - - - ); - })} - -
- { - if (el) el.indeterminate = selection.someChosen; - }} - onChange={selection.toggleAll} - /> - OrderBranchGoing toWaitingAmountStatus -
event.stopPropagation()} - style={{ - borderBottom: - '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)', - verticalAlign: 'top', - paddingTop: 12, - }} - > - selection.toggle(row.orderheaderid)} - /> - - - {row.orderid || `#${row.orderheaderid}`} - - {stamp(row.orderdate)} - - {branchLabel(row.locationname)} - {row.applocation} - - {row.deliverycustomer || '—'} - {row.deliveryaddress || row.deliverysuburb} - - {waited === null ? ( - — - ) : ( - - - {shortAge(waited)} - - )} - - {moneyExact(orderValue(row))} - - - - {/* 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 ? ( - - ) : ( - - {verdict.reason} - - )} -
-
-
- ); -} - -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 ; if (rows.length === 0) { @@ -822,6 +597,17 @@ function OrdersTable({ + {/* The tick column, first — the old console's position, and the + one place a person looks for it. */} + {showBranch ? : 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 ( - onOpen(row)}> + 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. */} + @@ -1512,7 +1326,16 @@ const ViewHint = () => ( View → ); -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 ( onClick(); } }} - style={{ cursor: 'pointer' }} + style={{ + cursor: 'pointer', + ...(isPicked ? { background: 'var(--color-brand-tint)' } : {}), + }} > {children} diff --git a/src/features/store-admin/pages/deliveries.css b/src/features/store-admin/pages/deliveries.css index 1a0b930..6aad1b6 100644 --- a/src/features/store-admin/pages/deliveries.css +++ b/src/features/store-admin/pages/deliveries.css @@ -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
+ + # OrderBranch event.stopPropagation()} + style={{ + borderBottom: + '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)', + verticalAlign: 'top', + paddingTop: 11, + }} + > + selection.toggle(row.orderheaderid)} + /> + {index + 1}