deliveries

This commit is contained in:
2026-09-03 19:13:04 +05:30
parent 25073659d8
commit 743aa93e20
11 changed files with 1787 additions and 10 deletions

View File

@@ -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<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

@@ -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<string | null>(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 (
<Text type="body" size="xsm" color="secondary">
This job is {status}. Nothing further to record.
</Text>
);
}
return (
<VStack gap={1}>
<div className="progress-moves">
{MOVES.map((step) => (
<button
key={step.to}
type="button"
className="progress-move"
data-danger={step.to === 'cancelled'}
disabled={move.isPending || step.to === status}
onClick={() => {
setProblem(null);
move.mutate({ to: step.to, stamp: step.stamp });
}}
>
{step.label}
</button>
))}
</div>
{problem ? (
<Text type="body" size="xsm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : (
<Text type="body" size="xsm" color="secondary">
Use these only when the rider's app has not. Marking a job delivered here also closes the
order.
</Text>
)}
</VStack>
);
}

View File

@@ -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.
</Text>
)}
{/* 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. */}
<DeliveryProgress job={job} />
</VStack>
) : order?.rider ? (
<VStack gap={1}>

View File

@@ -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> = {}): 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 }), '');
});

View File

@@ -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<number>): 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<number> {
const out = new Set<number>();
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<number>,
): 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<number>,
): 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<number>,
): { 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(' · ');
}

View File

@@ -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 (
<VStack gap={3}>
<PageHeader
@@ -194,11 +263,11 @@ export function SalesPage() {
label="Online Orders"
icon={<ShoppingCart size={14} />}
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' ? (
<>
<TabButton
label="Orders"
@@ -207,6 +276,18 @@ 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} />}
@@ -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' ? (
<HStack gap={0.5} wrap="wrap">
{STATUS_TABS.map((entry) => (
<StatusTab
@@ -304,6 +386,72 @@ 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' ? (
<>
@@ -425,13 +573,225 @@ 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
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 ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
<OrderDetailDrawer
row={freshRow(detail, allOrders, allDeliveries)}
kind={detail.kind}
onClose={() => setDetail(null)}
/>
) : null}
</VStack>
);
}
/**
* 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<typeof useSelection>;
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({

View File

@@ -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 <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);
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. */
.assign-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
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 {
font: 600 13px/1.3 var(--font-sans);
color: var(--color-brand);
font-variant-numeric: tabular-nums;
}
/* The tick column. Its own cell so clicking a box never opens the row. */
.tick-cell { width: 34px; padding-left: 12px; }
/* ── 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. */
.progress-moves { display: flex; gap: 6px; flex-wrap: wrap; }
.progress-move {
height: 30px;
padding: 0 12px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface);
color: var(--color-ink-2);
font: 500 12.5px/1 var(--font-sans);
cursor: pointer;
}
.progress-move:hover:not(:disabled) { background: var(--color-surface-sunken); }
.progress-move:disabled { color: var(--color-ink-4); cursor: not-allowed; }
.progress-move[data-danger='true']:not(:disabled) { color: #b3261e; border-color: #f0cfcd; }
.progress-move:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }