From 743aa93e20d4aa692acf725e587dd9e722b96582 Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 3 Sep 2026 19:13:04 +0530 Subject: [PATCH] deliveries --- src/api/deliveries.ts | 91 +++++ src/api/types.ts | 80 +++- .../store-admin/AssignRiderDrawer.tsx | 291 ++++++++++++++ src/features/store-admin/DeliveryProgress.tsx | 101 +++++ .../store-admin/OrderDetailDrawer.tsx | 6 + .../store-admin/assignDelivery.test.ts | 293 ++++++++++++++ src/features/store-admin/assignDelivery.ts | 345 ++++++++++++++++ src/features/store-admin/pages/SalesPage.tsx | 378 +++++++++++++++++- src/features/store-admin/pages/deliveries.css | 188 +++++++++ src/queries/hooks.ts | 21 + src/queries/keys.ts | 3 + 11 files changed, 1787 insertions(+), 10 deletions(-) create mode 100644 src/api/deliveries.ts create mode 100644 src/features/store-admin/AssignRiderDrawer.tsx create mode 100644 src/features/store-admin/DeliveryProgress.tsx create mode 100644 src/features/store-admin/assignDelivery.test.ts create mode 100644 src/features/store-admin/assignDelivery.ts create mode 100644 src/features/store-admin/pages/deliveries.css diff --git a/src/api/deliveries.ts b/src/api/deliveries.ts new file mode 100644 index 0000000..9a71f4f --- /dev/null +++ b/src/api/deliveries.ts @@ -0,0 +1,91 @@ +import { api, WEB } from './client'; +import type { RiderInfo } from './types'; +import type { DeliveryDraft } from '@/features/store-admin/assignDelivery'; + +/** + * Deliveries — creating them, moving them along, and finding a rider. + * + * Separate from `insights.ts`, which only READS deliveries. The split is the + * same one the backend makes: `getdeliveries` answers "what is out there", and + * these three change it. + */ + +export interface RiderQuery { + /** + * The delivery region. This is the scope that works. + * + * `tenantid` is also accepted and returns nothing: the filter is + * `app_users.tenantid`, which is not set on rider accounts. Verified against + * production — `?tenantid=1135` gives an empty list while `?applocationid=1` + * gives the rider working that tenant's shops. Passing the tenant would have + * produced an empty picker with no error to explain it. + */ + applocationid: number; +} + +export const deliveriesApi = { + /** + * Riders on duty right now. + * + * "On duty" is the backend's word, not a filter added here: the query wants + * `app_userpools.onduty = 1` and a `riderlogs` row stamped today with + * `logstatus = 0`. So this list empties overnight and refills as riders clock + * on, and an empty answer means nobody has started their shift — not that + * the shop has no riders. The picker has to say which. + */ + riders: (query: RiderQuery) => + api.list(`${WEB}/partners/getriders`, { applocationid: query.applocationid }), + + /** + * Hand orders to a rider. + * + * An array, always, because that is what the endpoint takes and because one + * call is one transaction: each row inserts a `deliveries` row, copies it to + * `deliveryqueues` for the rider's app, and moves the parent order's status. + * Verified with three orders in a single call — three deliveries, three + * queue rows, nothing duplicated. + * + * The response carries no ids, only a message, so callers refetch rather + * than patching a row in place. + */ + assign: (rows: DeliveryDraft[]) => + api.post(`${WEB}/deliveries/createdeliveries`, rows), + + /** + * Move a delivery along its ladder, or hand it to a different rider. + * + * `deliveryid` is the only field the backend insists on — it finds the row + * with it and derives the parent order from that rather than trusting the + * caller's `orderheaderid`, which is why a partial payload is safe here. + * + * Note what the backend does NOT do: `picked` updates the delivery and stops + * there, leaving the order at its previous status. Only pending, delivered + * and cancelled are mirrored onto the order. + */ + update: (body: UpdateDelivery) => + api.put(`${WEB}/deliveries/updatedelivery`, body), +}; + +/** + * The delivery lifecycle, lowercase, as `deliveries.orderstatus` stores it. + * + * `skipped` is real and reachable — the rider got there and nobody was in — + * but it is written by the rider's app, not from here, so it is not offered. + */ +export const DELIVERY_STEPS = ['pending', 'accepted', 'arrived', 'picked', 'active', 'delivered'] as const; +export type DeliveryStep = (typeof DELIVERY_STEPS)[number]; + +export interface UpdateDelivery { + deliveryid: number; + orderstatus: string; + /** Sent when known so the backend does not have to look it up. */ + orderheaderid?: number; + /** Set to move the job to a different rider. */ + userid?: number; + assigntime?: string; + starttime?: string; + arrivaltime?: string; + pickuptime?: string; + deliverytime?: string; + canceltime?: string; +} diff --git a/src/api/types.ts b/src/api/types.ts index 40a5d55..fe2b97c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -441,7 +441,14 @@ export interface OrderRow { pickupcontactno?: string; pickupaddress?: string; pickupsuburb?: string; - /** The delivery half. Blank on an order nobody has been assigned to. */ + /** + * The delivery half. Blank on an order nobody has been assigned to. + * + * `deliveryid` is the one to read for "has a rider been assigned yet" — it is + * 0 until `createdeliveries` runs, and it is 0 on every unassigned row in + * production. `rider` looks like the same test and is not: it is filled from + * a join and is empty on rows that DO have a delivery. + */ deliveryid?: number; rider?: string; ridercontactno?: string; @@ -450,6 +457,77 @@ export interface OrderRow { pickuptime?: string; deliverytime?: string; canceltime?: string; + + /* + * The ids a delivery row has to be built from. + * + * All of these are on the wire and none were typed, because nothing read + * them until assignment existed. `createdeliveries` copies them onto the + * `deliveries` row, and the reads that follow join on them — so an order + * that arrives without one produces a delivery that cannot be seen. See + * `assignDelivery.ts` for which of them are load-bearing. + */ + applocationid?: number; + partnerid?: number; + configid?: number; + moduleid?: number; + categoryid?: number; + subcategoryid?: number; + /** Who placed the order. Distinct from `deliverycustomerid`, which is 0. */ + customerid?: number; + deliverycustomerid?: number; + deliverylocationid?: number; + pickuplocationid?: number; + pickuplat?: string; + pickuplong?: string; + deliverylat?: string; + deliverylong?: string; + droplat?: string; + droplon?: string; + kms?: string; + /** + * Empty on every production order, so it CANNOT be used to tell a delivery + * order from a collection. Typed only so nobody reaches for it and quietly + * filters the whole list away — the drop address is the real test. + */ + deliverytype?: string; +} + +/** + * One rider, from `GET /partners/getriders` (`models.RiderInfo`). + * + * Only riders who are ON DUTY TODAY appear. The query requires a `riderlogs` + * row stamped today with `logstatus = 0` and `app_userpools.onduty = 1`, so an + * empty list means "nobody has clocked on", not "this shop has no riders" — + * a distinction the picker has to make, or an operator spends the morning + * wondering why the fleet vanished. + * + * `password` is deliberately absent for the same reason it is on `Staff`: the + * endpoint returns it in clear, and not typing it is what stops a cell + * rendering one. + */ +export interface RiderInfo { + userid: number; + firstname?: string; + lastname?: string; + fullname?: string; + contactno?: string; + partnerid?: number; + applocationid?: number; + applocation?: string; + vehiclename?: string; + vehicleno?: string; + licenseno?: string; + shiftid?: number; + /** Shift window, as `HH:MM:SS`. */ + starttime?: string; + endtime?: string; + /** Today's log. `logstatus` 0 is on duty. */ + logdate?: string; + login?: string; + logout?: string; + logstatus?: number; + status?: string; } /** diff --git a/src/features/store-admin/AssignRiderDrawer.tsx b/src/features/store-admin/AssignRiderDrawer.tsx new file mode 100644 index 0000000..6403ace --- /dev/null +++ b/src/features/store-admin/AssignRiderDrawer.tsx @@ -0,0 +1,291 @@ +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/DeliveryProgress.tsx b/src/features/store-admin/DeliveryProgress.tsx new file mode 100644 index 0000000..3ea77ac --- /dev/null +++ b/src/features/store-admin/DeliveryProgress.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { errorMessage } from '@/api/client'; +import { deliveriesApi } from '@/api/deliveries'; +import type { DeliveryRow } from '@/api/types'; +import { queryKeys } from '@/queries/keys'; +import { stampNow } from './assignDelivery'; + +/** + * Moving a delivery along from the back office. + * + * The rider's app drives this ladder in the ordinary case. This is for when it + * cannot: a rider whose phone died, a job cancelled by phone, a delivery that + * was completed but never marked. Without it the only way to correct a stuck + * job is a database write. + * + * ── Two things the backend does that shape this ───────────────────────────── + * + * `updatedelivery` mirrors the status onto the parent order for `pending`, + * `delivered` and `cancelled` — but NOT for `picked`, which updates the + * delivery and leaves the order where it was. So the order and its delivery + * legitimately disagree in the middle of a journey, and this does not try to + * paper over that. + * + * The lifecycle timestamp is sent explicitly. `Updates()` skips zero-valued + * struct fields, so a status sent without its stamp moved the status and left + * the column blank — which is how a delivery ends up marked delivered with no + * delivery time against it. + */ + +/** What can be set from here, in the order a job actually walks. */ +const MOVES = [ + { to: 'picked', label: 'Picked up', stamp: 'pickuptime' }, + { to: 'delivered', label: 'Delivered', stamp: 'deliverytime' }, + { to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' }, +] as const; + +/** The end states. Nothing follows them, so nothing is offered. */ +const SETTLED = ['delivered', 'cancelled']; + +export function DeliveryProgress({ job }: { job: DeliveryRow }) { + const client = useQueryClient(); + const [problem, setProblem] = useState(null); + + const status = (job.orderstatus ?? '').trim().toLowerCase(); + const isSettled = SETTLED.includes(status); + + const move = useMutation({ + mutationFn: ({ to, stamp }: { to: string; stamp: string }) => + deliveriesApi.update({ + deliveryid: job.deliveryid, + orderheaderid: job.orderheaderid ?? 0, + orderstatus: to, + [stamp]: stampNow(), + }), + onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.insights.all }), + onError: (error) => setProblem(errorMessage(error)), + }); + + if (isSettled) { + return ( + + This job is {status}. Nothing further to record. + + ); + } + + return ( + +
+ {MOVES.map((step) => ( + + ))} +
+ {problem ? ( + + {problem} + + ) : ( + + Use these only when the rider's app has not. Marking a job delivered here also closes the + order. + + )} +
+ ); +} diff --git a/src/features/store-admin/OrderDetailDrawer.tsx b/src/features/store-admin/OrderDetailDrawer.tsx index d4c91c7..198ab4d 100644 --- a/src/features/store-admin/OrderDetailDrawer.tsx +++ b/src/features/store-admin/OrderDetailDrawer.tsx @@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { Bike, Check, MapPin, Phone, X } from 'lucide-react'; import type { DeliveryRow, OrderRow } from '@/api/types'; +import { DeliveryProgress } from './DeliveryProgress'; import { branchLabel, moneyExact } from './format'; import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus'; @@ -268,6 +269,11 @@ export function OrderDetailDrawer({ No rider assigned yet. )} + {/* Recording progress by hand. Under the rider rather than beside + the status chip at the top: it is a correction, not the normal + way a job moves, and putting it in the header would invite it + to be used as one. */} +
) : order?.rider ? ( diff --git a/src/features/store-admin/assignDelivery.test.ts b/src/features/store-admin/assignDelivery.test.ts new file mode 100644 index 0000000..5f679e5 --- /dev/null +++ b/src/features/store-admin/assignDelivery.test.ts @@ -0,0 +1,293 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; +import { + DELIVERY_CREATED_STATUS, + assignability, + assignedFrom, + awaitingRider, + buildDeliveries, + buildDelivery, + isUnassigned, + needsDelivery, + riderName, + riderVehicle, + stampNow, + waitingMs, +} from './assignDelivery'; + +/* +Assigning a rider is an INSERT, not a status change, and every read that follows +joins on the row it writes. So these tests are mostly about the payload: a +delivery created with the wrong word or a missing id saves cleanly, reports +success, and then cannot be seen by anybody. + +The fixture is a real production order — Suriya Store 1135-379, verified on +2026-09-03 — rather than an invented one, because the fields that turned out to +matter are exactly the ones a plausible fixture would have got wrong. +*/ + +const order = (over: Partial = {}): OrderRow => ({ + orderheaderid: 150158, + orderid: '1135-379', + tenantid: 1135, + locationid: 1166, + applocationid: 1, + partnerid: 60, + configid: 1, + moduleid: 2, + categoryid: 2, + subcategoryid: 0, + orderdate: '2026-09-03T16:16:39+05:30', + orderstatus: 'created', + itemcount: 1, + orderamount: 80, + ordervalue: 80, + paymenttype: 42, + customerid: 6060, + deliveryid: 0, + deliverycustomer: 'anbu', + deliverycontactno: '7397177923', + deliveryaddress: 'Harmony Arcade, R.S. Puram, Coimbatore', + deliverylat: '11.0050853', + deliverylong: '76.9508624', + pickupcustomer: 'Ragul Stores', + pickupcontactno: '7402223869', + pickupaddress: '412-419, R.S. Puram, Coimbatore', + ...over, +}); + +const rider: RiderInfo = { + userid: 897, + firstname: 'Varun', + lastname: 'Edward', + fullname: 'Varun Edward', + contactno: '8248136667', + vehiclename: 'Bike', + vehicleno: 'TN 38CV 1535', +}; + +const branch: TenantLocation = { + locationid: 1166, + tenantid: 1135, + applocationid: 1, + locationname: 'Suriya Store RS Puram', + status: 'Active', +}; + +/* ── Which orders are waiting ────────────────────────────────────────────── */ + +test('unassigned is read from deliveryid, not from the rider name', () => { + assert.equal(isUnassigned(order({ deliveryid: 0 })), true); + assert.equal(isUnassigned(order({ deliveryid: undefined })), true); + assert.equal(isUnassigned(order({ deliveryid: 128302 })), false); +}); + +test('an order the deliveries list already holds is not offered again', () => { + /* + `orders.deliveryid` was never written and the orders query aliased the column + away, so every delivery that existed before this work sits against an order + whose deliveryid still reads 0. Both are fixed, but only for new rows — + without the deliveries list as a second signal, the queue would offer to + assign production's entire delivery history a second time. + */ + const already = assignedFrom([{ orderheaderid: 150158 }, { orderheaderid: 999 }]); + assert.equal(isUnassigned(order({ deliveryid: 0 }), already), false); + assert.equal(isUnassigned(order({ orderheaderid: 777, deliveryid: 0 }), already), true); +}); + +test('the assigned set ignores deliveries with no order attached', () => { + const built = assignedFrom([{ orderheaderid: 5 }, { orderheaderid: 0 }, {}]); + assert.deepEqual([...built], [5]); +}); + +test('an assigned order is not offered again just because the rider name is blank', () => { + // `rider` comes from a join and is empty on plenty of rows that do have a + // delivery. Testing it would offer to assign jobs already out with somebody. + assert.equal(isUnassigned(order({ deliveryid: 128302, rider: '' })), false); +}); + +test('the drop address decides what needs a rider, because deliverytype is always empty', () => { + assert.equal(needsDelivery(order({ deliverytype: '' })), true); + assert.equal(needsDelivery(order({ deliveryaddress: '', deliverycustomer: '' })), false); +}); + +test('the queue keeps only unassigned delivery orders that are still live', () => { + const rows = [ + order({ orderheaderid: 1 }), + order({ orderheaderid: 2, deliveryid: 999 }), + order({ orderheaderid: 3, orderstatus: 'delivered' }), + order({ orderheaderid: 4, orderstatus: 'cancelled' }), + order({ orderheaderid: 5, deliveryaddress: '', deliverycustomer: '' }), + ]; + assert.deepEqual( + awaitingRider(rows).map((r) => r.orderheaderid), + [1], + ); +}); + +test('the queue puts the longest wait first, unlike every other list on the page', () => { + const rows = [ + order({ orderheaderid: 2, orderdate: '2026-09-03T12:00:00+05:30' }), + order({ orderheaderid: 1, orderdate: '2026-09-03T09:00:00+05:30' }), + order({ orderheaderid: 3, orderdate: '2026-09-03T15:00:00+05:30' }), + ]; + assert.deepEqual( + awaitingRider(rows).map((r) => r.orderheaderid), + [1, 2, 3], + ); +}); + +test('waiting time is null when undatable, rather than claiming no wait', () => { + const now = new Date('2026-09-03T17:16:39+05:30').getTime(); + assert.equal(waitingMs(order(), now), 3_600_000); + assert.equal(waitingMs(order({ orderdate: '' })), null); +}); + +/* ── Whether it can be assigned at all ───────────────────────────────────── */ + +test('an ordinary unassigned order can be assigned', () => { + assert.equal(assignability(order(), branch).canAssign, true); +}); + +test('a branch with no delivery region is refused, because the job would be invisible', () => { + /* + The guard that matters most. `createdeliveries` inserts a zero applocationid + happily and reports success, then three of the reads that follow join + app_location and app_locationconfig on it — so the job exists, sits in the + rider's queue, and appears on no screen. Suriya Store NSN (location 1172) + carries applocationid 0 in production today, so this is not hypothetical. + */ + const verdict = assignability(order({ applocationid: 0 }), { ...branch, applocationid: 0 }); + assert.equal(verdict.canAssign, false); + assert.match(verdict.reason, /region/i); +}); + +test('the region falls back to the branch when the order row is missing it', () => { + assert.equal(assignability(order({ applocationid: 0 }), branch).canAssign, true); +}); + +test('an order that already has a rider is refused', () => { + assert.equal(assignability(order({ deliveryid: 128302 }), branch).canAssign, false); +}); + +test('an order with no drop address is refused, and says so', () => { + const verdict = assignability(order({ deliveryaddress: '', deliverycustomer: '' }), branch); + assert.equal(verdict.canAssign, false); + assert.match(verdict.reason, /address/i); +}); + +/* ── The payload ─────────────────────────────────────────────────────────── */ + +const NOW = new Date('2026-09-03T17:30:00+05:30'); + +test('the job is created as lowercase pending, so it reaches the rider queue', () => { + /* + The rider's app reads `getdeliveryqueues`, which filters + WHERE orderstatus = 'pending' AND userid = ?. "Assigned" is the obvious word + to reach for — the ORDER ladder uses capitalised words — and it creates a job + the assigned rider can never see. + */ + assert.equal(buildDelivery(order(), rider, branch, NOW).orderstatus, 'pending'); + assert.equal(DELIVERY_CREATED_STATUS, 'pending'); +}); + +test('the pickup address goes under its capitalised key', () => { + // `Pickupaddress`, alone among fifty lowercase JSON tags on models.Deliveries. + // Sending `pickupaddress` drops the address silently. + assert.equal( + buildDelivery(order(), rider, branch, NOW).Pickupaddress, + '412-419, R.S. Puram, Coimbatore', + ); +}); + +test('every id is carried across from the order rather than defaulted', () => { + const draft = buildDelivery(order(), rider, branch, NOW); + assert.equal(draft.orderheaderid, 150158); + assert.equal(draft.tenantid, 1135); + assert.equal(draft.locationid, 1166); + assert.equal(draft.applocationid, 1); + assert.equal(draft.partnerid, 60); + assert.equal(draft.configid, 1); + assert.equal(draft.moduleid, 2); + assert.equal(draft.categoryid, 2); + assert.equal(draft.userid, 897); +}); + +test('the job says what is being carried, which the old rows left at zero', () => { + // Production deliveries sit at itemcount 0 and orderamount 0 against orders + // worth ₹80, so the deliveries page cannot tell a rider what they have. + const draft = buildDelivery(order(), rider, branch, NOW); + assert.equal(draft.itemcount, 1); + assert.equal(draft.orderamount, 80); + 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'); + assert.equal(stampNow(new Date(2026, 0, 5, 9, 7, 3)), '2026-01-05 09:07:03'); +}); + +test('the branch supplies a pickup end the order does not carry', () => { + const thin = buildDelivery( + order({ pickupcustomer: '', pickupaddress: '', pickupcontactno: '' }), + rider, + { ...branch, address: '12, DB Road, RS Puram', contactno: '9876543212' }, + NOW, + ); + assert.equal(thin.pickupcustomer, 'Suriya Store RS Puram'); + assert.equal(thin.Pickupaddress, '12, DB Road, RS Puram'); + assert.equal(thin.pickupcontactno, '9876543212'); +}); + +/* ── Batches ─────────────────────────────────────────────────────────────── */ + +test('a batch builds one row per order', () => { + const { drafts, skipped } = buildDeliveries( + [order({ orderheaderid: 1 }), order({ orderheaderid: 2 })], + rider, + () => branch, + NOW, + ); + assert.deepEqual( + drafts.map((d) => d.orderheaderid), + [1, 2], + ); + assert.deepEqual(skipped, []); +}); + +test('a batch drops what cannot be assigned instead of sending it with the rest', () => { + /* + The endpoint reports one outcome for the whole array, so one bad row takes + the good ones down with it. Dropping it and naming it is the only way the + operator learns which order still needs attention. + */ + const { drafts, skipped } = buildDeliveries( + [order({ orderheaderid: 1 }), order({ orderheaderid: 2, applocationid: 0 }), order({ orderheaderid: 3 })], + rider, + (row) => (row.orderheaderid === 2 ? { ...branch, applocationid: 0 } : branch), + NOW, + ); + assert.deepEqual( + drafts.map((d) => d.orderheaderid), + [1, 3], + ); + assert.equal(skipped.length, 1); + assert.equal(skipped[0]?.row.orderheaderid, 2); + assert.match(skipped[0]?.reason ?? '', /region/i); +}); + +/* ── Labels ──────────────────────────────────────────────────────────────── */ + +test('a rider always gets a readable label', () => { + assert.equal(riderName(rider), 'Varun Edward'); + assert.equal(riderName({ userid: 1, firstname: 'Varun', lastname: 'Edward' }), 'Varun Edward'); + assert.equal(riderName({ userid: 42 }), 'Rider 42'); +}); + +test('the vehicle line is empty rather than a stray separator when nothing is recorded', () => { + assert.equal(riderVehicle(rider), 'Bike · TN 38CV 1535'); + assert.equal(riderVehicle({ userid: 1 }), ''); +}); diff --git a/src/features/store-admin/assignDelivery.ts b/src/features/store-admin/assignDelivery.ts new file mode 100644 index 0000000..0654229 --- /dev/null +++ b/src/features/store-admin/assignDelivery.ts @@ -0,0 +1,345 @@ +import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; + +/** + * Turning an order into a delivery job. + * + * Assigning a rider is not a status change — it is an INSERT. `createdeliveries` + * writes a row in `deliveries`, copies it into `deliveryqueues` for the rider's + * app, and moves `orders.orderstatus`, all in one transaction. Until it runs, + * a delivery order is not on the deliveries page at all, because deliveries are + * their own table and not a filter over orders. + * + * That makes the payload load-bearing in a way a status write never is: every + * read that comes afterwards joins on the ids written here, and a join that + * misses drops the row silently. Everything in this file exists to stop a + * delivery being created that nobody can then see. + * + * Pure, so the rules below can be tested without a rider, an order or a + * network. + */ + +/** + * Has this order been turned into a delivery yet? + * + * Two signals, because one of them is younger than the data. + * + * - `deliveryid` on the order row. The direct answer, and it was unavailable + * until this work: `orders.deliveryid` was never written, and the orders + * query aliased the column away so the field came back 0 on every row ever + * returned. Both are fixed, but only for deliveries created from now on. + * - `assigned`, the set of `orderheaderid`s the deliveries list already holds. + * This is what covers the rows that predate the fix — production's existing + * deliveries all sit against an order whose `deliveryid` is still null. + * + * `rider` reads like a third signal and is not: it comes from a join on the + * rider's `app_users` row and is empty on plenty of orders that DO have a + * delivery, so testing it would offer to assign jobs already out with somebody. + */ +export function isUnassigned(row: OrderRow, assigned?: ReadonlySet): boolean { + if (row.deliveryid) return false; + return !assigned?.has(row.orderheaderid); +} + +/** + * The orders the deliveries list says are spoken for. + * + * Built from whatever `getdeliveries` returned for the same window the orders + * came from, which is the pairing the Sales page already has in hand. + */ +export function assignedFrom(deliveries: readonly { orderheaderid?: number }[]): Set { + const out = new Set(); + for (const row of deliveries) if (row.orderheaderid) out.add(row.orderheaderid); + return out; +} + +/** + * Does this order need a rider at all? + * + * NOT `deliverytype`. That field is empty on every order in production — all + * 379 of Suriya Store's — so filtering on it hides the entire list. The drop + * address is the honest test: an order with somewhere to be delivered to is a + * delivery, and a counter sale has neither address nor customer. + */ +export function needsDelivery(row: OrderRow): boolean { + return Boolean((row.deliveryaddress ?? '').trim() || (row.deliverycustomer ?? '').trim()); +} + +/** + * Orders waiting for a rider, oldest first. + * + * Oldest first, against the newest-first convention everywhere else on the + * page, because this is a work queue rather than a log: the order that has + * waited longest is the one to deal with next. + */ +export function awaitingRider( + rows: readonly OrderRow[], + assigned?: ReadonlySet, +): OrderRow[] { + return rows + .filter((row) => isUnassigned(row, assigned) && needsDelivery(row) && !isFinished(row)) + .sort((a, b) => placedAt(a) - placedAt(b)); +} + +/** An order that is already done or called off wants no rider. */ +function isFinished(row: OrderRow): boolean { + const status = (row.orderstatus ?? '').trim().toLowerCase(); + return status.includes('deliver') || status.includes('cancel') || status.includes('reject'); +} + +/** When the order was placed, as an instant. 0 when the stamp is unreadable. */ +export function placedAt(row: OrderRow): number { + const at = new Date(row.orderdate ?? '').getTime(); + return Number.isNaN(at) ? 0 : at; +} + +/** How long an order has been waiting for a rider. `null` when undatable. */ +export function waitingMs(row: OrderRow, now = Date.now()): number | null { + const at = placedAt(row); + if (at === 0) return null; + return Math.max(0, now - at); +} + +/* ── Whether it can be assigned at all ───────────────────────────────────── */ + +export interface Assignability { + canAssign: boolean; + /** Why not, in words for an operator. Empty when it can. */ + reason: string; +} + +/** + * Can a delivery for this order actually be created AND then seen? + * + * The second half is the part worth guarding. `createdeliveries` will happily + * insert a row with a zero `applocationid` and report success — but three of + * the reads that follow join `app_location` and `app_locationconfig` on it, so + * the job would exist, occupy the rider's queue, and never appear on any + * screen. Verified: Suriya Store NSN (location 1172) carries + * `applocationid: 0` today, so this is not hypothetical. + * + * Refusing up front is the honest answer. The alternative is a button that + * reports success and loses the order, which is the worst outcome available + * and exactly what "it worked on my test tenant" produces. + */ +export function assignability( + row: OrderRow, + branch: TenantLocation | undefined, + assigned?: ReadonlySet, +): Assignability { + const applocationid = row.applocationid || branch?.applocationid || 0; + + if (!row.orderheaderid) { + return { canAssign: false, reason: 'This order has no id to attach a delivery to.' }; + } + if (!applocationid) { + return { + canAssign: false, + reason: + 'This branch has no delivery region set, so a job created for it would not appear anywhere. Set the region on the branch first.', + }; + } + if (!row.locationid) { + return { canAssign: false, reason: 'This order is not attached to a branch.' }; + } + if (!needsDelivery(row)) { + return { canAssign: false, reason: 'This order has no delivery address.' }; + } + if (!isUnassigned(row, assigned)) { + return { canAssign: false, reason: 'A rider is already on this one.' }; + } + return { canAssign: true, reason: '' }; +} + +/* ── The payload ─────────────────────────────────────────────────────────── */ + +/** + * The status a new delivery is created with. + * + * `pending`, lowercase, and it matters more than it looks. The rider's app + * reads its jobs from `getdeliveryqueues`, which filters + * `WHERE a.orderstatus = 'pending' AND a.userid = ?`. Any other word — and + * "Assigned" is the obvious one to reach for, because the order side uses + * capitalised words — creates a job the assigned rider can never see. + * + * The delivery ladder is its own vocabulary and all of it is lowercase: + * pending → accepted → arrived → picked → active → delivered, with cancelled + * and skipped off to the side. + */ +export const DELIVERY_CREATED_STATUS = 'pending'; + +/** Fiesta wants `YYYY-MM-DD HH:MM:SS` in local time, not an ISO instant. */ +export function stampNow(now = new Date()): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` + + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + ); +} + +/** 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. + */ +export interface DeliveryDraft { + orderheaderid: number; + orderid: string; + tenantid: number; + locationid: number; + applocationid: number; + partnerid: number; + configid: number; + moduleid: number; + categoryid: number; + subcategoryid: number; + userid: number; + orderstatus: string; + deliverydate: string; + assigntime: string; + itemcount: number; + orderamount: number; + deliveryamt: number; + deliverycharges: number; + paymenttype: number; + customerid: number; + deliverycustomerid: number; + pickupcustomer: string; + pickupcontactno: string; + pickuplocationid: number; + Pickupaddress: string; + pickuplat: string; + pickuplon: string; + deliverycustomer: string; + deliverycontactno: string; + deliverylocationid: number; + deliveryaddress: string; + deliverylat: string; + deliverylong: string; + ordernotes: string; + kms: string; +} + +/** + * Build the delivery row for one order and one rider. + * + * Every id is carried across from the order rather than defaulted, because the + * delivery inherits the order's place in the world — the same tenant, branch, + * region, partner and category. The old console's rows show what happens when + * they are not: production deliveries sit there with `itemcount: 0` and + * `orderamount: 0` against orders worth ₹80, so the deliveries page cannot + * show a rider what they are carrying. Those are copied here. + * + * `branch` supplies the pickup end and the region when the order row is thin + * about them; the order always wins where it has a value. + */ +export function buildDelivery( + row: OrderRow, + rider: RiderInfo, + branch: TenantLocation | undefined, + now = new Date(), +): DeliveryDraft { + const at = stampNow(now); + return { + orderheaderid: row.orderheaderid, + orderid: row.orderid ?? '', + tenantid: row.tenantid ?? branch?.tenantid ?? 0, + locationid: row.locationid ?? branch?.locationid ?? 0, + applocationid: row.applocationid || branch?.applocationid || 0, + partnerid: row.partnerid ?? 0, + configid: row.configid ?? 0, + moduleid: row.moduleid ?? 0, + categoryid: row.categoryid ?? 0, + subcategoryid: row.subcategoryid ?? 0, + + 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), + assigntime: at, + + // What the rider is carrying, so the deliveries page can say so. + itemcount: row.itemcount ?? row.quantity ?? 0, + orderamount: row.orderamount ?? row.ordervalue ?? 0, + deliveryamt: row.ordervalue ?? row.orderamount ?? 0, + deliverycharges: row.deliverycharge ?? 0, + paymenttype: row.paymenttype ?? 0, + + customerid: row.customerid ?? 0, + deliverycustomerid: row.deliverycustomerid ?? 0, + + // Pickup: the shop. The order carries it, and the branch is the fallback + // for the orders that do not. + pickupcustomer: row.pickupcustomer || branch?.locationname || '', + pickupcontactno: row.pickupcontactno || branch?.contactno || '', + pickuplocationid: row.pickuplocationid || row.locationid || 0, + Pickupaddress: row.pickupaddress || branch?.address || '', + pickuplat: row.pickuplat || branch?.latitude || '', + pickuplon: row.pickuplong || branch?.longitude || '', + + // Drop: the shopper. No fallback — an order without these was refused by + // `assignability` before it reached here. + deliverycustomer: row.deliverycustomer ?? '', + deliverycontactno: row.deliverycontactno ?? '', + deliverylocationid: row.deliverylocationid ?? 0, + deliveryaddress: row.deliveryaddress ?? '', + deliverylat: row.deliverylat ?? '', + deliverylong: row.deliverylong ?? '', + + ordernotes: row.ordernotes ?? '', + kms: row.kms ?? '', + }; +} + +/** + * The whole batch, for orders that can take one. + * + * `createdeliveries` accepts an array and writes each row in the same + * transaction, so assigning eight orders to one rider is one call. Orders that + * fail `assignability` are dropped rather than sent — the endpoint reports one + * outcome for the whole array, so a row that cannot work takes the good ones + * down with it. + */ +export function buildDeliveries( + rows: readonly OrderRow[], + rider: RiderInfo, + branchOf: (row: OrderRow) => TenantLocation | undefined, + now = new Date(), + assigned?: ReadonlySet, +): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } { + const drafts: DeliveryDraft[] = []; + const skipped: { row: OrderRow; reason: string }[] = []; + + for (const row of rows) { + const branch = branchOf(row); + const verdict = assignability(row, branch, assigned); + if (verdict.canAssign) { + drafts.push(buildDelivery(row, rider, branch, now)); + } else { + skipped.push({ row, reason: verdict.reason }); + } + } + + return { drafts, skipped }; +} + +/** How a rider reads in the picker. Falls back through the name fields. */ +export function riderName(rider: RiderInfo): string { + const full = (rider.fullname ?? '').trim(); + if (full) return full; + const joined = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim(); + return joined || `Rider ${rider.userid}`; +} + +/** The vehicle line under the name. Empty when nothing is recorded. */ +export function riderVehicle(rider: RiderInfo): string { + return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · '); +} diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index 5aef6cd..abc263a 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from 'react'; +import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; -import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react'; +import { Banknote, Bike, Calculator, Clock, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX, X } from 'lucide-react'; import type { DateRange } from '@/api/insights'; -import type { DeliveryRow, OrderRow, PosSale } from '@/api/types'; +import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; import { SectionHeader } from '@/components/SectionHeader'; @@ -34,8 +35,12 @@ import { import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer'; import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer'; import { shortAge } from '../posStatus'; +import { AssignRiderDrawer } from '../AssignRiderDrawer'; +import { assignability, assignedFrom, awaitingRider, waitingMs } from '../assignDelivery'; +import { useSelection } from '@/components/useSelection'; +import './deliveries.css'; -type Tab = 'orders' | 'deliveries' | 'counter'; +type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter'; /** * Sales — app orders, delivery jobs and counter bills. @@ -160,6 +165,70 @@ export function SalesPage() { return { value, charges, unassigned }; }, [deliveryRows]); + /* ── The assignment queue ─────────────────────────────────────────────── */ + + /** + * Orders with nobody riding them yet. + * + * Drawn from the ORDERS read, not the deliveries one, and that is the whole + * point: an order with no rider has no delivery row, so it appears on the + * deliveries page under no filter at all. Before this queue existed there was + * nowhere in the console such an order could be seen. + * + * Not filtered by the status strip. A shop working through its backlog wants + * every order still waiting, and "Pending" is not even the status they arrive + * with — production orders sit at `created`. + */ + /** + * Which orders the deliveries list already accounts for. + * + * The order row's own `deliveryid` is the direct answer, but it is only + * populated for deliveries created after this backend ships — nothing wrote + * `orders.deliveryid` before, and the orders query aliased the column away. + * Every delivery already in production sits against an order that still + * reads 0, so without this the queue would offer to assign them all again. + */ + const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]); + + const queue = useMemo(() => awaitingRider(allOrders, assigned), [allOrders, assigned]); + + const branchOf = useMemo( + () => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid), + [branches], + ); + + /** + * Only the rows that can actually be sent are selectable. + * + * Not `queue` — an order whose branch has no delivery region has its own tick + * box disabled, and handing every queue id to the selection meant "select + * all" ticked it anyway: the box went grey-and-checked and the bar counted + * six when only five could go. The drawer caught it and named it, which is + * the safety net working, but a count that disagrees with what happens is + * how a person stops trusting the ticks. + */ + const selectable = useMemo( + () => + queue + .filter((row) => assignability(row, branchOf(row), assigned).canAssign) + .map((row) => row.orderheaderid), + [queue, branchOf, assigned], + ); + + const picked = useSelection(selectable); + const [isAssigning, setAssigning] = useState(false); + + const pickedOrders = useMemo( + () => queue.filter((row) => picked.has(row.orderheaderid)), + [queue, picked], + ); + + /** The oldest wait in the queue — the number that says how bad it is. */ + const longestWait = useMemo(() => { + const waits = queue.map((row) => waitingMs(row)).filter((ms): ms is number => ms !== null); + return waits.length > 0 ? Math.max(...waits) : null; + }, [queue]); + return ( } badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined} - isActive={tab === 'orders' || tab === 'deliveries'} - onClick={() => setTab(tab === 'deliveries' ? 'deliveries' : 'orders')} + isActive={tab !== 'counter'} + onClick={() => setTab(tab === 'counter' ? 'orders' : tab)} /> - {(tab === 'orders' || tab === 'deliveries') ? ( + {tab !== 'counter' ? ( <> 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')} + /> } @@ -229,8 +310,9 @@ export function SalesPage() { /> {/* The status strip. Only orders and deliveries have a lifecycle — a - counter bill is rung and done, so it has nothing to filter by. */} - {tab !== 'counter' ? ( + counter bill is rung and done, so it has nothing to filter by, and + the assignment queue is already the answer to one status question. */} + {tab === 'orders' || tab === 'deliveries' ? ( {STATUS_TABS.map((entry) => ( ) : 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' ? ( <> @@ -425,13 +573,225 @@ 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 + showing. Marking a job delivered updated the list behind and left the + drawer reading "Pending", still offering the buttons — the state was + a snapshot taken when it opened. Looking it up keeps it honest, and + falls back to the captured row while a refetch is in flight. */} {detail ? ( - setDetail(null)} /> + setDetail(null)} + /> ) : null}
); } +/** + * The open row, as the list currently has it. + * + * Keyed on `deliveryid` for a job and `orderheaderid` for an order — both are + * stable primary keys, unlike `orderid`, which is not unique in this tenant's + * data. Returns the captured row when the list has not caught up yet, so the + * drawer never blanks mid-refetch. + */ +function freshRow( + detail: { row: OrderRow | DeliveryRow; kind: RowKind }, + orders: readonly OrderRow[], + deliveries: readonly DeliveryRow[], +): OrderRow | DeliveryRow { + if (detail.kind === 'delivery') { + const id = (detail.row as DeliveryRow).deliveryid; + return deliveries.find((row) => row.deliveryid === id) ?? detail.row; + } + const id = detail.row.orderheaderid; + return orders.find((row) => row.orderheaderid === id) ?? detail.row; +} + +/** Past this, a shopper is waiting long enough that somebody should notice. */ +const FIFTEEN_MINUTES = 15 * 60_000; +const THIRTY_MINUTES = 30 * 60_000; + +/* ── The assignment queue ────────────────────────────────────────────────── */ + +/** + * Orders with nobody riding them. + * + * Its own table rather than a filter on `OrdersTable`, because it answers a + * different question and therefore wants different columns. Money and item + * counts matter when you are reading a day's trading; when you are deciding who + * takes what next, only three things do — where it is going, how long it has + * been sitting there, and whether it can be sent at all. + */ +function QueueTable({ + rows, + isLoading, + showBranch, + selection, + branchOf, + assigned, + onOpen, +}: { + rows: OrderRow[]; + isLoading: boolean; + showBranch: boolean; + selection: ReturnType; + 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({ diff --git a/src/features/store-admin/pages/deliveries.css b/src/features/store-admin/pages/deliveries.css new file mode 100644 index 0000000..1a0b930 --- /dev/null +++ b/src/features/store-admin/pages/deliveries.css @@ -0,0 +1,188 @@ +/* ══ 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. */ + +/* ── 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