assign option
This commit is contained in:
@@ -10,6 +10,29 @@ import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
|
||||
* these three change it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The rider has no device registered.
|
||||
*
|
||||
* Its own error type because the remedy differs from a transport failure: the
|
||||
* rider must open the app and sign in, not retry. Collapsed into a generic
|
||||
* "notification failed", an operator assumes the network is at fault and tries
|
||||
* again forever.
|
||||
*/
|
||||
export class RiderNotReachableError extends Error {
|
||||
constructor(message = 'This rider has no device registered, so they were not told.') {
|
||||
super(message);
|
||||
this.name = 'RiderNotReachableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** What the rider is told. Kept together so the wording stays consistent. */
|
||||
export const RIDER_MESSAGE = {
|
||||
assigned: (count: number) =>
|
||||
count === 1
|
||||
? 'An order has been assigned to you. Kindly accept and process the delivery.'
|
||||
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
|
||||
} as const;
|
||||
|
||||
export interface RiderQuery {
|
||||
/**
|
||||
* The delivery region. This is the scope that works.
|
||||
@@ -51,6 +74,29 @@ export const deliveriesApi = {
|
||||
assign: (rows: DeliveryDraft[]) =>
|
||||
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
|
||||
|
||||
/**
|
||||
* Tell a rider they have work.
|
||||
*
|
||||
* Deliberately NOT chained into `assign` — the deliveries are committed by
|
||||
* the time this runs, so a failed push must not read as a failed assignment.
|
||||
* Callers fire it afterwards and report the two outcomes separately: a rider
|
||||
* who was never told has work sitting unseen, which the operator needs to
|
||||
* know without being told the assignment failed.
|
||||
*
|
||||
* The backend holds the Firebase credentials; this only relays.
|
||||
*/
|
||||
notify: (token: string, body: string) => {
|
||||
// Checked here rather than at the server: posting an empty token returns
|
||||
// FCM's "exactly one of token, topic or condition must be specified",
|
||||
// which reads as a server fault rather than as a rider who has never
|
||||
// opened the app. Verified against the live endpoint.
|
||||
if (!token.trim()) throw new RiderNotReachableError();
|
||||
return api.post<unknown>(`${WEB}/utils/notifyuser`, {
|
||||
token: token.trim(),
|
||||
notification: { title: 'NearleXpress', body },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a delivery along its ladder, or hand it to a different rider.
|
||||
*
|
||||
|
||||
@@ -518,6 +518,8 @@ export interface RiderInfo {
|
||||
vehiclename?: string;
|
||||
vehicleno?: string;
|
||||
licenseno?: string;
|
||||
/** The device to push to. Empty for a rider who has never opened the app. */
|
||||
userfcmtoken?: string;
|
||||
shiftid?: number;
|
||||
/** Shift window, as `HH:MM:SS`. */
|
||||
starttime?: string;
|
||||
|
||||
156
src/features/store-admin/AssignBar.tsx
Normal file
156
src/features/store-admin/AssignBar.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { UserCheck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
RIDER_MESSAGE,
|
||||
RiderNotReachableError,
|
||||
deliveriesApi,
|
||||
} from '@/api/deliveries';
|
||||
import type { OrderRow, TenantLocation } from '@/api/types';
|
||||
import { useRiders } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
|
||||
|
||||
/**
|
||||
* Assigning riders, from the orders table itself.
|
||||
*
|
||||
* A bar above the list rather than a page or a drawer of its own, which is what
|
||||
* the old console did and the right shape for the job: assigning is something
|
||||
* you do WHILE reading the day's orders, glancing between the drop addresses
|
||||
* and who is free. A separate screen makes you leave the list to act on it and
|
||||
* come back to check.
|
||||
*
|
||||
* It appears when rows are ticked and stays afterwards to report what happened,
|
||||
* because the result is the only record — `createdeliveries` answers with a
|
||||
* message and no ids.
|
||||
*/
|
||||
export interface AssignBarProps {
|
||||
/** The ticked orders. */
|
||||
orders: OrderRow[];
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
onClear: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) {
|
||||
const client = useQueryClient();
|
||||
const [riderId, setRiderId] = useState('');
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
|
||||
// Every order in a batch shares a region in practice — they are one branch's
|
||||
// orders — so the first one carrying a region decides which fleet to ask for.
|
||||
const region =
|
||||
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
|
||||
|
||||
const riders = useRiders(region || undefined);
|
||||
const fleet = riders.data ?? [];
|
||||
const rider = fleet.find((entry) => String(entry.userid) === riderId);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!rider) throw new Error('Pick a rider first');
|
||||
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned);
|
||||
if (drafts.length === 0) {
|
||||
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
|
||||
}
|
||||
await deliveriesApi.assign(drafts);
|
||||
return { count: drafts.length, skipped: skipped.length };
|
||||
},
|
||||
onSuccess: async ({ count, skipped }) => {
|
||||
const who = riderName(rider!);
|
||||
const base =
|
||||
skipped === 0
|
||||
? `${count} order${count === 1 ? '' : 's'} assigned to ${who}`
|
||||
: `${count} assigned to ${who} · ${skipped} could not be and are still waiting`;
|
||||
|
||||
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
||||
onDone();
|
||||
|
||||
/*
|
||||
* The push is fired after the write and reported separately.
|
||||
*
|
||||
* The deliveries exist either way, so a failed notification must not read
|
||||
* as a failed assignment — but it must still be visible, because a rider
|
||||
* who was never told has work sitting unseen. This is the old console's
|
||||
* contract and it is the right one.
|
||||
*/
|
||||
setOutcome(`${base} · telling them…`);
|
||||
try {
|
||||
await deliveriesApi.notify(rider!.userfcmtoken ?? '', RIDER_MESSAGE.assigned(count));
|
||||
setOutcome(`${base} · rider notified`);
|
||||
} catch (error) {
|
||||
setOutcome(
|
||||
`${base} · NOT notified — ${
|
||||
error instanceof RiderNotReachableError
|
||||
? 'this rider has no device registered'
|
||||
: 'the push failed, tell them another way'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) => setOutcome(errorMessage(error)),
|
||||
});
|
||||
|
||||
const options = fleet.map((entry) => ({
|
||||
value: String(entry.userid),
|
||||
label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
|
||||
<span className="assign-bar-count">
|
||||
<UserCheck size={15} />
|
||||
{orders.length} selected
|
||||
</span>
|
||||
|
||||
<div className="assign-bar-picker">
|
||||
<Selector
|
||||
label="Choose a rider"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={riderId}
|
||||
onChange={(value) => {
|
||||
setRiderId(String(value));
|
||||
setOutcome(null);
|
||||
}}
|
||||
options={options}
|
||||
isDisabled={orders.length === 0}
|
||||
/* The empty-list wording carries the actual information — which of
|
||||
the three situations you are in — so it lives in the placeholder
|
||||
rather than being flattened to "Select…". "No riders on duty" is a
|
||||
shift that has not started; the region case is a branch somebody
|
||||
has to configure. Neither is a fault in the orders. */
|
||||
placeholder={
|
||||
region === 0
|
||||
? 'No delivery region on this branch'
|
||||
: riders.isLoading
|
||||
? 'Loading riders…'
|
||||
: options.length > 0
|
||||
? 'Select a rider…'
|
||||
: 'No rider has clocked on today'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label={send.isPending ? 'Assigning…' : 'Assign rider'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<UserCheck size={14} />}
|
||||
isDisabled={!rider || orders.length === 0 || send.isPending}
|
||||
onClick={() => send.mutate()}
|
||||
/>
|
||||
<Button label="Clear" variant="ghost" size="sm" onClick={onClear} />
|
||||
|
||||
{outcome ? (
|
||||
<span className="assign-bar-outcome" role="status">
|
||||
{outcome}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Bike, Check, MapPin, Phone } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { deliveriesApi } from '@/api/deliveries';
|
||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { useRiders } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
assignability,
|
||||
buildDeliveries,
|
||||
riderName,
|
||||
riderVehicle,
|
||||
waitingMs,
|
||||
} from './assignDelivery';
|
||||
import { money } from './format';
|
||||
import { shortAge } from './posStatus';
|
||||
import { orderValue } from './orderStatus';
|
||||
|
||||
/**
|
||||
* Handing orders to a rider.
|
||||
*
|
||||
* One drawer for one order and for twenty, because the call underneath is the
|
||||
* same either way: `createdeliveries` takes an array and writes it in one
|
||||
* transaction. Building a single-order path as well would be two code paths
|
||||
* for one endpoint, and the second one is always the one that rots.
|
||||
*
|
||||
* ── What this screen is careful about ───────────────────────────────────────
|
||||
*
|
||||
* Assigning is an INSERT, not a status change, and the row it inserts is what
|
||||
* every later read joins on. An order whose branch has no delivery region set
|
||||
* produces a job that saves cleanly and then cannot be seen by anybody — so
|
||||
* those are held back here, named, and left in the queue rather than sent and
|
||||
* quietly lost. `assignDelivery.ts` holds the rules; this shows their answers.
|
||||
*/
|
||||
export interface AssignRiderDrawerProps {
|
||||
orders: OrderRow[];
|
||||
/** The branch each order belongs to, for the pickup end and the region. */
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
/**
|
||||
* Orders the deliveries list already accounts for. Covers the rows assigned
|
||||
* before the backend started writing `orders.deliveryid`.
|
||||
*/
|
||||
assigned?: ReadonlySet<number>;
|
||||
onClose: () => void;
|
||||
/** Called once at least one job was created, so the caller can clear its ticks. */
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function AssignRiderDrawer({
|
||||
orders,
|
||||
branchOf,
|
||||
assigned,
|
||||
onClose,
|
||||
onDone,
|
||||
}: AssignRiderDrawerProps) {
|
||||
const client = useQueryClient();
|
||||
const [chosen, setChosen] = useState<number | null>(null);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
// Every order in a batch shares a region in practice — they are the same
|
||||
// branch's orders — so the first one that has a region decides which fleet
|
||||
// to ask for.
|
||||
const region =
|
||||
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
|
||||
|
||||
const riders = useRiders(region || undefined);
|
||||
const fleet = riders.data ?? [];
|
||||
|
||||
const ready = orders.filter((row) => assignability(row, branchOf(row), assigned).canAssign);
|
||||
const held = orders
|
||||
.map((row) => ({ row, reason: assignability(row, branchOf(row), assigned).reason }))
|
||||
.filter((entry) => entry.reason !== '');
|
||||
|
||||
const rider = fleet.find((entry) => entry.userid === chosen);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!rider) throw new Error('Pick a rider first');
|
||||
const { drafts } = buildDeliveries(ready, rider, branchOf, new Date(), assigned);
|
||||
if (drafts.length === 0) throw new Error('None of these orders can be assigned');
|
||||
return deliveriesApi.assign(drafts);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// The response carries a message and no ids, so both lists are refetched
|
||||
// rather than patched: the orders now have a deliveryid and the
|
||||
// deliveries page has rows it did not have a moment ago.
|
||||
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={ready.length === 1 ? 'Assign a rider' : `Assign ${ready.length} orders`}
|
||||
subtitle={
|
||||
ready.length === 1
|
||||
? ready[0]?.orderid || `#${ready[0]?.orderheaderid}`
|
||||
: 'They all go to the same rider'
|
||||
}
|
||||
width={480}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
{/* ── What is being handed over ───────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
{ready.length === 1 ? 'This order' : `These ${ready.length} orders`}
|
||||
</Text>
|
||||
<div className="assign-list">
|
||||
{ready.map((row) => (
|
||||
<div key={row.orderheaderid} className="assign-row">
|
||||
<div className="assign-row-main">
|
||||
<strong>{row.deliverycustomer || 'Customer'}</strong>
|
||||
<span>{row.deliveryaddress || row.deliverysuburb || 'No address'}</span>
|
||||
</div>
|
||||
<div className="assign-row-side">
|
||||
<strong>{money(orderValue(row))}</strong>
|
||||
<span>{ageLabel(row)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
{/* Orders that came in with the selection but cannot be sent. Named
|
||||
rather than silently dropped — the operator has to know one is
|
||||
still sitting there, and why. */}
|
||||
{held.length > 0 ? (
|
||||
<div className="assign-held">
|
||||
<HStack gap={1} align="start">
|
||||
<AlertTriangle size={15} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="body" size="sm">
|
||||
{held.length === 1
|
||||
? 'One order is not going with this batch'
|
||||
: `${held.length} orders are not going with this batch`}
|
||||
</Text>
|
||||
{held.map(({ row, reason }) => (
|
||||
<Text key={row.orderheaderid} type="body" size="xsm" color="secondary">
|
||||
{row.orderid || `#${row.orderheaderid}`} — {reason}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── The fleet ───────────────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
Riders on duty
|
||||
</Text>
|
||||
<RiderPicker
|
||||
riders={fleet}
|
||||
isLoading={riders.isLoading}
|
||||
hasRegion={region !== 0}
|
||||
chosen={chosen}
|
||||
onChoose={(id) => {
|
||||
setChosen(id);
|
||||
setProblem(null);
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{problem ? (
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{problem}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
|
||||
<Button
|
||||
label={send.isPending ? 'Assigning…' : 'Assign'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Bike size={14} />}
|
||||
isDisabled={!rider || ready.length === 0 || send.isPending}
|
||||
onClick={() => send.mutate()}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
The rider sees the job in their app straight away, and the order moves onto the
|
||||
deliveries list.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rider takes it.
|
||||
*
|
||||
* The three empty states are different problems with different fixes, so they
|
||||
* say different things. "Nobody has clocked on" is a shift that has not
|
||||
* started; "no delivery region" is a branch someone has to configure; and a
|
||||
* fleet of one is neither — it just looks like a bug if the list is silent
|
||||
* about it.
|
||||
*/
|
||||
function RiderPicker({
|
||||
riders,
|
||||
isLoading,
|
||||
hasRegion,
|
||||
chosen,
|
||||
onChoose,
|
||||
}: {
|
||||
riders: RiderInfo[];
|
||||
isLoading: boolean;
|
||||
hasRegion: boolean;
|
||||
chosen: number | null;
|
||||
onChoose: (userid: number) => void;
|
||||
}) {
|
||||
if (!hasRegion) {
|
||||
return (
|
||||
<div className="assign-empty">
|
||||
<MapPin size={16} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
These orders have no delivery region, so there is no fleet to ask. Set the region on the
|
||||
branch first.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="assign-empty">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Finding riders…
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (riders.length === 0) {
|
||||
return (
|
||||
<div className="assign-empty">
|
||||
<Bike size={16} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No rider has clocked on today. The list fills as riders start their shift — nothing is
|
||||
wrong with the orders.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rider-list">
|
||||
{riders.map((rider) => {
|
||||
const isChosen = rider.userid === chosen;
|
||||
return (
|
||||
<button
|
||||
key={rider.userid}
|
||||
type="button"
|
||||
className="rider-option"
|
||||
data-chosen={isChosen}
|
||||
aria-pressed={isChosen}
|
||||
onClick={() => onChoose(rider.userid)}
|
||||
>
|
||||
<span className="rider-mark">{isChosen ? <Check size={13} /> : <Bike size={13} />}</span>
|
||||
<span className="rider-text">
|
||||
<strong>{riderName(rider)}</strong>
|
||||
<span>{riderVehicle(rider) || 'No vehicle recorded'}</span>
|
||||
</span>
|
||||
{rider.contactno ? (
|
||||
<span className="rider-contact">
|
||||
<Phone size={11} />
|
||||
{rider.contactno}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How long this one has been waiting. Blank rather than "0m" when undatable. */
|
||||
function ageLabel(row: OrderRow): string {
|
||||
const waited = waitingMs(row);
|
||||
return waited === null ? '' : `waiting ${shortAge(waited)}`;
|
||||
}
|
||||
@@ -223,13 +223,44 @@ test('the job says what is being carried, which the old rows left at zero', () =
|
||||
assert.equal(draft.deliveryamt, 80);
|
||||
});
|
||||
|
||||
test('times are local wall-clock, not ISO instants', () => {
|
||||
const draft = buildDelivery(order(), rider, branch, NOW);
|
||||
assert.equal(draft.assigntime, '2026-09-03 17:30:00');
|
||||
assert.equal(draft.deliverydate, '2026-09-03');
|
||||
test('assigntime is local wall-clock, not an ISO instant', () => {
|
||||
assert.equal(buildDelivery(order(), rider, branch, NOW).assigntime, '2026-09-03 17:30:00');
|
||||
assert.equal(stampNow(new Date(2026, 0, 5, 9, 7, 3)), '2026-01-05 09:07:03');
|
||||
});
|
||||
|
||||
test('the job takes the date it is FOR, walking the order row for it', () => {
|
||||
/*
|
||||
`getdeliveries` filters on deliverydate, so a job written without one is
|
||||
excluded from every dated view, and a job stamped today when it is scheduled
|
||||
for tomorrow lands in the wrong day's window. An order carries its slot as
|
||||
`deliverytime` — there is no `deliverydate` on an order row — hence the chain,
|
||||
which is the old console's and is the old console's for this reason.
|
||||
*/
|
||||
assert.equal(
|
||||
buildDelivery(order({ deliverydate: '2026-09-05' }), rider, branch, NOW).deliverydate,
|
||||
'2026-09-05',
|
||||
);
|
||||
assert.equal(
|
||||
buildDelivery(order({ deliverytime: '2026-09-06 11:00:00' }), rider, branch, NOW).deliverydate,
|
||||
'2026-09-06 11:00:00',
|
||||
);
|
||||
// Neither present: the order's own date.
|
||||
assert.equal(
|
||||
buildDelivery(order(), rider, branch, NOW).deliverydate,
|
||||
'2026-09-03T16:16:39+05:30',
|
||||
);
|
||||
});
|
||||
|
||||
test('a job is never written date-less, because a date-less job is invisible', () => {
|
||||
const draft = buildDelivery(
|
||||
order({ deliverydate: '', deliverytime: '', orderdate: '' }),
|
||||
rider,
|
||||
branch,
|
||||
NOW,
|
||||
);
|
||||
assert.equal(draft.deliverydate, '2026-09-03 17:30:00');
|
||||
});
|
||||
|
||||
test('the branch supplies a pickup end the order does not carry', () => {
|
||||
const thin = buildDelivery(
|
||||
order({ pickupcustomer: '', pickupaddress: '', pickupcontactno: '' }),
|
||||
|
||||
@@ -176,18 +176,15 @@ export function stampNow(now = new Date()): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Just the date half, for `deliverydate`. */
|
||||
export function dateOf(now = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the `createdeliveries` array.
|
||||
*
|
||||
* `Pickupaddress` is capitalised. That is not a typo here — it is the JSON tag
|
||||
* on `models.Deliveries`, alone among fifty lowercase ones, and sending
|
||||
* `pickupaddress` instead means the address is simply dropped.
|
||||
* `Pickupaddress` is capitalised because that is the JSON tag on
|
||||
* `models.Deliveries`, alone among fifty lowercase ones. Matching it exactly is
|
||||
* a courtesy rather than a requirement: Go's decoder matches field names
|
||||
* case-insensitively, so the old console's lowercase `pickupaddress` landed
|
||||
* fine — verified. Spelled as the model spells it so a reader grepping the Go
|
||||
* struct finds this.
|
||||
*/
|
||||
export interface DeliveryDraft {
|
||||
orderheaderid: number;
|
||||
@@ -261,9 +258,18 @@ export function buildDelivery(
|
||||
|
||||
userid: rider.userid,
|
||||
orderstatus: DELIVERY_CREATED_STATUS,
|
||||
// The date the job is for, and the moment it was handed out. Both are
|
||||
// written by us: the backend stamps neither on create.
|
||||
deliverydate: dateOf(now),
|
||||
/*
|
||||
* The date the job is FOR, not today.
|
||||
*
|
||||
* An order row carries its scheduled slot as `deliverytime` — there is no
|
||||
* `deliverydate` on an order — so it is copied through, with the order date
|
||||
* and finally the assign stamp behind it. The fallback chain is the old
|
||||
* console's and the reason is its reason: `getdeliveries` filters on
|
||||
* `deliverydate`, so a delivery written without one is excluded from every
|
||||
* dated view, and a job stamped today when it is scheduled for tomorrow
|
||||
* shows up in the wrong day's window.
|
||||
*/
|
||||
deliverydate: row.deliverydate || row.deliverytime || row.orderdate || at,
|
||||
assigntime: at,
|
||||
|
||||
// What the rider is carrying, so the deliveries page can say so.
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Banknote, Bike, Calculator, Clock, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX, X } from 'lucide-react';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useDeliveries,
|
||||
useOrders,
|
||||
@@ -35,12 +34,12 @@ import {
|
||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||
import { shortAge } from '../posStatus';
|
||||
import { AssignRiderDrawer } from '../AssignRiderDrawer';
|
||||
import { assignability, assignedFrom, awaitingRider, waitingMs } from '../assignDelivery';
|
||||
import { AssignBar } from '../AssignBar';
|
||||
import { assignability, assignedFrom } from '../assignDelivery';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import './deliveries.css';
|
||||
|
||||
type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter';
|
||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
|
||||
/**
|
||||
* Sales — app orders, delivery jobs and counter bills.
|
||||
@@ -165,70 +164,49 @@ export function SalesPage() {
|
||||
return { value, charges, unassigned };
|
||||
}, [deliveryRows]);
|
||||
|
||||
/* ── The assignment queue ─────────────────────────────────────────────── */
|
||||
/* ── Assigning riders, from the orders list ───────────────────────────── */
|
||||
|
||||
/**
|
||||
* Orders with nobody riding them yet.
|
||||
*
|
||||
* Drawn from the ORDERS read, not the deliveries one, and that is the whole
|
||||
* point: an order with no rider has no delivery row, so it appears on the
|
||||
* deliveries page under no filter at all. Before this queue existed there was
|
||||
* nowhere in the console such an order could be seen.
|
||||
*
|
||||
* Not filtered by the status strip. A shop working through its backlog wants
|
||||
* every order still waiting, and "Pending" is not even the status they arrive
|
||||
* with — production orders sit at `created`.
|
||||
*/
|
||||
/**
|
||||
* Which orders the deliveries list already accounts for.
|
||||
*
|
||||
* The order row's own `deliveryid` is the direct answer, but it is only
|
||||
* populated for deliveries created after this backend ships — nothing wrote
|
||||
* `orders.deliveryid` before, and the orders query aliased the column away.
|
||||
* Every delivery already in production sits against an order that still
|
||||
* reads 0, so without this the queue would offer to assign them all again.
|
||||
* The order row's own `deliveryid` is the direct answer, and it is only
|
||||
* populated for deliveries created since the backend started writing it —
|
||||
* nothing wrote `orders.deliveryid` before, and the orders query aliased the
|
||||
* column away. Every delivery that predates that fix sits against an order
|
||||
* still reading 0, so without this the list would offer to assign the whole
|
||||
* of production's delivery history a second time.
|
||||
*/
|
||||
const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
|
||||
|
||||
const queue = useMemo(() => awaitingRider(allOrders, assigned), [allOrders, assigned]);
|
||||
|
||||
const branchOf = useMemo(
|
||||
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
||||
[branches],
|
||||
);
|
||||
|
||||
/**
|
||||
* Only the rows that can actually be sent are selectable.
|
||||
* The orders a rider can be put on, out of the ones currently listed.
|
||||
*
|
||||
* Not `queue` — an order whose branch has no delivery region has its own tick
|
||||
* box disabled, and handing every queue id to the selection meant "select
|
||||
* all" ticked it anyway: the box went grey-and-checked and the bar counted
|
||||
* six when only five could go. The drawer caught it and named it, which is
|
||||
* the safety net working, but a count that disagrees with what happens is
|
||||
* how a person stops trusting the ticks.
|
||||
* The selection is built from these rather than from every visible row, so
|
||||
* "select all" only ever ticks what can actually be sent. Handing it every
|
||||
* row instead ticked the disabled boxes too: they went grey-and-checked and
|
||||
* the bar counted more than it would assign, which is how a person stops
|
||||
* trusting the ticks.
|
||||
*/
|
||||
const selectable = useMemo(
|
||||
() =>
|
||||
queue
|
||||
orderRows
|
||||
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
||||
.map((row) => row.orderheaderid),
|
||||
[queue, branchOf, assigned],
|
||||
[orderRows, branchOf, assigned],
|
||||
);
|
||||
|
||||
const picked = useSelection(selectable);
|
||||
const [isAssigning, setAssigning] = useState(false);
|
||||
|
||||
const pickedOrders = useMemo(
|
||||
() => queue.filter((row) => picked.has(row.orderheaderid)),
|
||||
[queue, picked],
|
||||
() => orderRows.filter((row) => picked.has(row.orderheaderid)),
|
||||
[orderRows, picked],
|
||||
);
|
||||
|
||||
/** The oldest wait in the queue — the number that says how bad it is. */
|
||||
const longestWait = useMemo(() => {
|
||||
const waits = queue.map((row) => waitingMs(row)).filter((ms): ms is number => ms !== null);
|
||||
return waits.length > 0 ? Math.max(...waits) : null;
|
||||
}, [queue]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
@@ -276,18 +254,6 @@ export function SalesPage() {
|
||||
isActive={tab === 'orders'}
|
||||
onClick={() => setTab('orders')}
|
||||
/>
|
||||
{/* Between Orders and Deliveries because that is where the work
|
||||
sits: an order becomes a delivery by passing through here.
|
||||
The badge is the count of shoppers currently waiting on
|
||||
somebody in this room, which is why it is the one tab whose
|
||||
number is worth acting on. */}
|
||||
<TabButton
|
||||
label="To assign"
|
||||
icon={<UserX size={14} />}
|
||||
badge={queue.length || undefined}
|
||||
isActive={tab === 'toassign'}
|
||||
onClick={() => setTab('toassign')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Deliveries"
|
||||
icon={<Truck size={14} />}
|
||||
@@ -309,23 +275,6 @@ export function SalesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The status strip. Only orders and deliveries have a lifecycle — a
|
||||
counter bill is rung and done, so it has nothing to filter by, and
|
||||
the assignment queue is already the answer to one status question. */}
|
||||
{tab === 'orders' || tab === 'deliveries' ? (
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
{STATUS_TABS.map((entry) => (
|
||||
<StatusTab
|
||||
key={entry.key}
|
||||
label={entry.label}
|
||||
n={tabCounts[entry.key]}
|
||||
isActive={status === entry.key}
|
||||
onClick={() => setStatus(entry.key)}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{/* ── Orders ──────────────────────────────────────────────────────── */}
|
||||
{tab === 'orders' ? (
|
||||
<>
|
||||
@@ -361,14 +310,35 @@ export function SalesPage() {
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Orders"
|
||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
|
||||
<FilterRow
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
search={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
|
||||
}
|
||||
/>
|
||||
{/* Above the table, not below it and not in a drawer.
|
||||
Assigning is something you do WHILE reading the day's orders —
|
||||
glancing between the drop addresses and who is free — so the
|
||||
control belongs beside the list rather than on a screen you have
|
||||
to leave the list for. This is where the old console put it. */}
|
||||
{picked.count > 0 ? (
|
||||
<AssignBar
|
||||
orders={pickedOrders}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onClear={picked.clear}
|
||||
onDone={picked.clear}
|
||||
/>
|
||||
) : null}
|
||||
<OrdersTable
|
||||
rows={orderRows}
|
||||
isLoading={orders.isLoading}
|
||||
showBranch={selected === null}
|
||||
selection={picked}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
||||
/>
|
||||
{orderRows.length > 0 ? (
|
||||
@@ -386,71 +356,6 @@ export function SalesPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── To assign ───────────────────────────────────────────────────── */}
|
||||
{tab === 'toassign' ? (
|
||||
<>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Waiting for a rider"
|
||||
value={count(queue.length)}
|
||||
note="nobody is carrying these yet"
|
||||
tone={queue.length > 0 ? 'warning' : 'success'}
|
||||
icon={<UserX size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Longest wait"
|
||||
value={longestWait === null ? '—' : shortAge(longestWait)}
|
||||
note="since the order was placed"
|
||||
tone={longestWait !== null && longestWait > THIRTY_MINUTES ? 'error' : 'neutral'}
|
||||
icon={<Clock size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Value held up"
|
||||
value={money(queue.reduce((sum, row) => sum + orderValue(row), 0))}
|
||||
note="across the queue"
|
||||
tone="neutral"
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Waiting for a rider"
|
||||
action={
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Oldest first
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<QueueTable
|
||||
rows={queue}
|
||||
isLoading={orders.isLoading}
|
||||
showBranch={selected === null}
|
||||
selection={picked}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
||||
/>
|
||||
{picked.count > 0 ? (
|
||||
<div className="assign-bar">
|
||||
<span className="assign-bar-count">
|
||||
{picked.count} order{picked.count === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
<HStack gap={1} align="center">
|
||||
<BarAction label="Clear" icon={<X size={14} />} onClick={picked.clear} />
|
||||
<Button
|
||||
label="Assign a rider"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Bike size={14} />}
|
||||
onClick={() => setAssigning(true)}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── Deliveries ──────────────────────────────────────────────────── */}
|
||||
{tab === 'deliveries' ? (
|
||||
@@ -487,9 +392,13 @@ export function SalesPage() {
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Deliveries"
|
||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
|
||||
<FilterRow
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
search={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
|
||||
}
|
||||
/>
|
||||
<DeliveriesTable
|
||||
rows={deliveryRows}
|
||||
@@ -549,12 +458,11 @@ export function SalesPage() {
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Counter bills"
|
||||
action={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
||||
}
|
||||
/>
|
||||
{/* No status strip here: a counter bill is rung and done, so it
|
||||
has no lifecycle to filter by. The search keeps the row. */}
|
||||
<HStack justify="end" wrap="wrap">
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
||||
</HStack>
|
||||
<BillsTable
|
||||
rows={billRows}
|
||||
isLoading={billPages.some((page) => page.isLoading)}
|
||||
@@ -573,15 +481,6 @@ export function SalesPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isAssigning && pickedOrders.length > 0 ? (
|
||||
<AssignRiderDrawer
|
||||
orders={pickedOrders}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onClose={() => setAssigning(false)}
|
||||
onDone={picked.clear}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* The row is re-read from the live list on every render rather than
|
||||
used as captured, because the drawer can now CHANGE the row it is
|
||||
@@ -620,23 +519,51 @@ function freshRow(
|
||||
const id = detail.row.orderheaderid;
|
||||
return orders.find((row) => row.orderheaderid === id) ?? detail.row;
|
||||
}
|
||||
|
||||
/** Past this, a shopper is waiting long enough that somebody should notice. */
|
||||
const FIFTEEN_MINUTES = 15 * 60_000;
|
||||
const THIRTY_MINUTES = 30 * 60_000;
|
||||
|
||||
/* ── The assignment queue ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Orders with nobody riding them.
|
||||
* The status filters and the search, on one line above the table.
|
||||
*
|
||||
* Its own table rather than a filter on `OrdersTable`, because it answers a
|
||||
* different question and therefore wants different columns. Money and item
|
||||
* counts matter when you are reading a day's trading; when you are deciding who
|
||||
* takes what next, only three things do — where it is going, how long it has
|
||||
* been sitting there, and whether it can be sent at all.
|
||||
* This row replaces the "Orders" / "Deliveries" headings that used to sit here.
|
||||
* They named the table the tab had already named — two words of chrome between
|
||||
* the numbers and the rows — while the filters sat above the KPI cards, so the
|
||||
* control and the list it narrowed were separated by four cards.
|
||||
*
|
||||
* Below the cards is where the filters belong: the cards summarise everything
|
||||
* in view, the filters decide what is in view, and the table shows it. Reading
|
||||
* downward now follows that order.
|
||||
*/
|
||||
function QueueTable({
|
||||
function FilterRow({
|
||||
status,
|
||||
counts,
|
||||
onStatus,
|
||||
search,
|
||||
}: {
|
||||
status: StatusKey;
|
||||
counts: Record<StatusKey, number>;
|
||||
onStatus: (key: StatusKey) => void;
|
||||
search: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
{STATUS_TABS.map((entry) => (
|
||||
<StatusTab
|
||||
key={entry.key}
|
||||
label={entry.label}
|
||||
n={counts[entry.key]}
|
||||
isActive={status === entry.key}
|
||||
onClick={() => onStatus(entry.key)}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{search}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Orders table ────────────────────────────────────────────────────────── */
|
||||
|
||||
function OrdersTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
@@ -652,158 +579,6 @@ function QueueTable({
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
onOpen: (row: OrderRow) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="orders" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Bike size={22} />}
|
||||
title="Every order has a rider"
|
||||
body="Nothing in this date range is waiting to be assigned. New orders appear here as they come in."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select every order in the queue"
|
||||
checked={selection.allChosen}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.someChosen;
|
||||
}}
|
||||
onChange={selection.toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Going to</Th>
|
||||
<Th>Waiting</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const verdict = assignability(row, branchOf(row), assigned);
|
||||
const waited = waitingMs(row);
|
||||
return (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||
{/* The tick stops the click here. Clicking a checkbox to
|
||||
select and getting a drawer instead is the fastest way to
|
||||
make an operator stop trusting the boxes. */}
|
||||
<td
|
||||
className="tick-cell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
borderBottom:
|
||||
'1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
verticalAlign: 'top',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select order ${row.orderid || row.orderheaderid}`}
|
||||
checked={selection.has(row.orderheaderid)}
|
||||
/* An order that cannot be assigned cannot be ticked.
|
||||
Letting it into a batch means the whole array is
|
||||
refused, taking the good orders with it. */
|
||||
disabled={!verdict.canAssign}
|
||||
onChange={() => selection.toggle(row.orderheaderid)}
|
||||
/>
|
||||
</td>
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.orderdate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td>
|
||||
<Primary>{branchLabel(row.locationname)}</Primary>
|
||||
<Secondary>{row.applocation}</Secondary>
|
||||
</Td>
|
||||
) : null}
|
||||
<Td>
|
||||
<Primary w={200}>{row.deliverycustomer || '—'}</Primary>
|
||||
<Secondary w={200}>{row.deliveryaddress || row.deliverysuburb}</Secondary>
|
||||
</Td>
|
||||
<Td>
|
||||
{waited === null ? (
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||
) : (
|
||||
<span className="waited" data-tone={waitTone(waited)}>
|
||||
<Clock size={11} />
|
||||
{shortAge(waited)}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td isStrong>
|
||||
<Mono>{moneyExact(orderValue(row))}</Mono>
|
||||
</Td>
|
||||
<Td>
|
||||
<Chip
|
||||
label={row.orderstatus}
|
||||
color={statusColor(ORDER_STATUS, row.orderstatus)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
{/* The reason is on the row rather than only in the drawer.
|
||||
A branch with no delivery region is a configuration
|
||||
problem, and the person who can see it is the one
|
||||
looking at this list. */}
|
||||
{verdict.canAssign ? (
|
||||
<ViewHint />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: '#8a5a00',
|
||||
display: 'block',
|
||||
maxWidth: 190,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{verdict.reason}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function waitTone(ms: number): 'neutral' | 'warning' | 'critical' {
|
||||
if (ms >= THIRTY_MINUTES) return 'critical';
|
||||
if (ms >= FIFTEEN_MINUTES) return 'warning';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/* ── Orders table ────────────────────────────────────────────────────────── */
|
||||
|
||||
function OrdersTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: OrderRow[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
onOpen: (row: OrderRow) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="orders" />;
|
||||
if (rows.length === 0) {
|
||||
@@ -822,6 +597,17 @@ function OrdersTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{/* The tick column, first — the old console's position, and the
|
||||
one place a person looks for it. */}
|
||||
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
|
||||
<CheckboxInput
|
||||
label="Select every order that can be assigned"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={selection.allChosen ? true : selection.someChosen ? 'indeterminate' : false}
|
||||
onChange={selection.toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<Th>#</Th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
@@ -840,8 +626,36 @@ function OrdersTable({
|
||||
{rows.map((row, index) => {
|
||||
const cod = row.collectionamt ?? 0;
|
||||
const amount = orderValue(row);
|
||||
const verdict = assignability(row, branchOf(row), assigned);
|
||||
const isPicked = selection.has(row.orderheaderid);
|
||||
return (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}>
|
||||
{/* The tick swallows its own click. Reaching for a checkbox
|
||||
and getting a drawer instead is the fastest way to make an
|
||||
operator stop trusting the boxes. */}
|
||||
<td
|
||||
className="tick-cell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
borderBottom:
|
||||
'1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
verticalAlign: 'top',
|
||||
paddingTop: 11,
|
||||
}}
|
||||
>
|
||||
<CheckboxInput
|
||||
label={`Select order ${row.orderid || row.orderheaderid}`}
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={isPicked}
|
||||
/* Disabled rather than absent, so the column stays a
|
||||
column and the row explains itself on hover instead of
|
||||
leaving a silent gap. */
|
||||
isDisabled={!verdict.canAssign}
|
||||
disabledMessage={verdict.reason}
|
||||
onChange={() => selection.toggle(row.orderheaderid)}
|
||||
/>
|
||||
</td>
|
||||
<Td isMuted>
|
||||
<Mono>{index + 1}</Mono>
|
||||
</Td>
|
||||
@@ -1512,7 +1326,16 @@ const ViewHint = () => (
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-brand)' }}>View →</span>
|
||||
);
|
||||
|
||||
function Row({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
function Row({
|
||||
children,
|
||||
onClick,
|
||||
isPicked,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
/** Ticked rows take a brand wash, so a selection is visible while scrolling. */
|
||||
isPicked?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
@@ -1524,7 +1347,10 @@ function Row({ children, onClick }: { children: React.ReactNode; onClick: () =>
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
...(isPicked ? { background: 'var(--color-brand-tint)' } : {}),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
|
||||
@@ -1,176 +1,65 @@
|
||||
/* ══ Assigning riders ══════════════════════════════════════════════════════
|
||||
Built on the site's tokens and on the Console's .panel/.pill vocabulary, so
|
||||
the drawer and the queue read as the same product as everything around them.
|
||||
Only what is particular to assignment is defined here. */
|
||||
Assignment lives in the Orders table, not on a page of its own — so this is
|
||||
a tick column and a toolbar, and nothing more. Built on the site's tokens so
|
||||
the bar reads as part of the same table it sits above. */
|
||||
|
||||
/* ── The orders in the drawer ───────────────────────────────────────────── */
|
||||
/* A list, not a table: this is a confirmation of what is about to be handed
|
||||
over, and the columns of the queue behind it would only be repeated here. */
|
||||
.assign-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.assign-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.assign-row:last-child { border-bottom: none; }
|
||||
.assign-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.assign-row-main strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||
.assign-row-main span {
|
||||
font: 400 11.5px/1.35 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
/* Addresses run long and the money column must not be pushed off screen. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.assign-row-side { display: flex; flex-direction: column; gap: 2px; text-align: right; }
|
||||
.assign-row-side strong {
|
||||
font: 600 13px/1.3 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.assign-row-side span { font: 400 11px/1.3 var(--font-sans); color: var(--color-ink-3); white-space: nowrap; }
|
||||
|
||||
/* ── Orders held back ───────────────────────────────────────────────────── */
|
||||
/* Amber, not red. Nothing has failed — these were never sent, and the operator
|
||||
still has them. Red would say something was lost. */
|
||||
.assign-held {
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #f2d9a8;
|
||||
border-radius: 10px;
|
||||
background: #fdf6e8;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.assign-held svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── Empty states ───────────────────────────────────────────────────────── */
|
||||
.assign-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px dashed var(--color-line);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface-subtle);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
.assign-empty svg { flex: none; }
|
||||
|
||||
/* ── The rider picker ───────────────────────────────────────────────────── */
|
||||
/* Buttons rather than a <select>: there are rarely more than a handful of
|
||||
riders on duty, and the vehicle and phone number are half of how an operator
|
||||
picks between two names they only half recognise. A dropdown hides both. */
|
||||
.rider-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.rider-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
/* ── The tick column ────────────────────────────────────────────────────── */
|
||||
/* First column of the table, which is where the old console put it and where a
|
||||
person looks for it. Fixed width so the header box and the row boxes line up
|
||||
whatever the rest of the row does. */
|
||||
.tick-cell {
|
||||
width: 38px;
|
||||
padding-left: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rider-option:hover { border-color: var(--color-line-strong, var(--color-ink-4)); }
|
||||
.rider-option[data-chosen='true'] {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
.rider-option:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
.rider-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
.rider-option[data-chosen='true'] .rider-mark {
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
}
|
||||
.rider-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.rider-text strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||
.rider-text span { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||
.rider-contact {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font: 500 11.5px/1 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── The waiting badge on a queue row ───────────────────────────────────── */
|
||||
/* How long an order has sat unassigned is the one fact that decides what an
|
||||
operator does next, so it is not left as a timestamp to subtract. Amber past
|
||||
fifteen minutes, red past thirty — a shopper who ordered half an hour ago and
|
||||
has no rider is the reason to open this page. */
|
||||
.waited {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font: 500 11.5px/1.5 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.waited[data-tone='warning'] { background: #fdf6e8; color: #8a5a00; }
|
||||
.waited[data-tone='critical'] { background: #fdefee; color: #b3261e; }
|
||||
|
||||
/* ── The queue's action bar ─────────────────────────────────────────────── */
|
||||
/* Sticks to the bottom while the operator works down a long list, so the
|
||||
button is never a scroll away from the ticks that armed it. */
|
||||
/* ── The assign bar ─────────────────────────────────────────────────────── */
|
||||
/* Above the table, appearing when rows are ticked and staying afterwards to
|
||||
report what happened — `createdeliveries` answers with a message and no ids,
|
||||
so this line is the only record of the result. */
|
||||
.assign-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 12px;
|
||||
background: var(--color-brand-tint);
|
||||
box-shadow: 0 -6px 20px -12px rgb(15 23 42 / 0.25);
|
||||
}
|
||||
|
||||
.assign-bar-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 600 13px/1.3 var(--font-sans);
|
||||
color: var(--color-brand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The tick column. Its own cell so clicking a box never opens the row. */
|
||||
.tick-cell { width: 34px; padding-left: 12px; }
|
||||
/* Wide enough for "Meera Raj · Scooter · TN 37 AB 4410" without the name being
|
||||
the part that gets clipped, capped so it does not push the buttons off. */
|
||||
.assign-bar-picker {
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
/* Pushed to the far end so the result reads as an outcome rather than as
|
||||
another control. Wraps to its own line on a narrow window rather than
|
||||
squeezing the picker. */
|
||||
.assign-bar-outcome {
|
||||
margin-left: auto;
|
||||
font: 500 12px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-2);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Recording progress by hand ─────────────────────────────────────────── */
|
||||
/* Three plain buttons rather than a dropdown-and-save. Each is one decision
|
||||
the operator has already made before opening the drawer; a two-step control
|
||||
would add a confirmation to a correction that is itself reversible. */
|
||||
/* Three plain buttons rather than a dropdown-and-save. Each is one decision the
|
||||
operator has already made before opening the drawer; a two-step control would
|
||||
add a confirmation to a correction that is itself reversible. */
|
||||
.progress-moves { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.progress-move {
|
||||
height: 30px;
|
||||
|
||||
Reference in New Issue
Block a user