assign option
This commit is contained in:
@@ -10,6 +10,29 @@ import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
|
|||||||
* these three change it.
|
* 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 {
|
export interface RiderQuery {
|
||||||
/**
|
/**
|
||||||
* The delivery region. This is the scope that works.
|
* The delivery region. This is the scope that works.
|
||||||
@@ -51,6 +74,29 @@ export const deliveriesApi = {
|
|||||||
assign: (rows: DeliveryDraft[]) =>
|
assign: (rows: DeliveryDraft[]) =>
|
||||||
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
|
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.
|
* Move a delivery along its ladder, or hand it to a different rider.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -518,6 +518,8 @@ export interface RiderInfo {
|
|||||||
vehiclename?: string;
|
vehiclename?: string;
|
||||||
vehicleno?: string;
|
vehicleno?: string;
|
||||||
licenseno?: string;
|
licenseno?: string;
|
||||||
|
/** The device to push to. Empty for a rider who has never opened the app. */
|
||||||
|
userfcmtoken?: string;
|
||||||
shiftid?: number;
|
shiftid?: number;
|
||||||
/** Shift window, as `HH:MM:SS`. */
|
/** Shift window, as `HH:MM:SS`. */
|
||||||
starttime?: string;
|
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);
|
assert.equal(draft.deliveryamt, 80);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('times are local wall-clock, not ISO instants', () => {
|
test('assigntime is local wall-clock, not an ISO instant', () => {
|
||||||
const draft = buildDelivery(order(), rider, branch, NOW);
|
assert.equal(buildDelivery(order(), rider, branch, NOW).assigntime, '2026-09-03 17:30:00');
|
||||||
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');
|
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', () => {
|
test('the branch supplies a pickup end the order does not carry', () => {
|
||||||
const thin = buildDelivery(
|
const thin = buildDelivery(
|
||||||
order({ pickupcustomer: '', pickupaddress: '', pickupcontactno: '' }),
|
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.
|
* One row of the `createdeliveries` array.
|
||||||
*
|
*
|
||||||
* `Pickupaddress` is capitalised. That is not a typo here — it is the JSON tag
|
* `Pickupaddress` is capitalised because that is the JSON tag on
|
||||||
* on `models.Deliveries`, alone among fifty lowercase ones, and sending
|
* `models.Deliveries`, alone among fifty lowercase ones. Matching it exactly is
|
||||||
* `pickupaddress` instead means the address is simply dropped.
|
* 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 {
|
export interface DeliveryDraft {
|
||||||
orderheaderid: number;
|
orderheaderid: number;
|
||||||
@@ -261,9 +258,18 @@ export function buildDelivery(
|
|||||||
|
|
||||||
userid: rider.userid,
|
userid: rider.userid,
|
||||||
orderstatus: DELIVERY_CREATED_STATUS,
|
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.
|
* The date the job is FOR, not today.
|
||||||
deliverydate: dateOf(now),
|
*
|
||||||
|
* 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,
|
assigntime: at,
|
||||||
|
|
||||||
// What the rider is carrying, so the deliveries page can say so.
|
// What the rider is carrying, so the deliveries page can say so.
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { DateRange } from '@/api/insights';
|
||||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
|
||||||
import {
|
import {
|
||||||
useDeliveries,
|
useDeliveries,
|
||||||
useOrders,
|
useOrders,
|
||||||
@@ -35,12 +34,12 @@ import {
|
|||||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||||
import { shortAge } from '../posStatus';
|
import { shortAge } from '../posStatus';
|
||||||
import { AssignRiderDrawer } from '../AssignRiderDrawer';
|
import { AssignBar } from '../AssignBar';
|
||||||
import { assignability, assignedFrom, awaitingRider, waitingMs } from '../assignDelivery';
|
import { assignability, assignedFrom } from '../assignDelivery';
|
||||||
import { useSelection } from '@/components/useSelection';
|
import { useSelection } from '@/components/useSelection';
|
||||||
import './deliveries.css';
|
import './deliveries.css';
|
||||||
|
|
||||||
type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter';
|
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sales — app orders, delivery jobs and counter bills.
|
* Sales — app orders, delivery jobs and counter bills.
|
||||||
@@ -165,70 +164,49 @@ export function SalesPage() {
|
|||||||
return { value, charges, unassigned };
|
return { value, charges, unassigned };
|
||||||
}, [deliveryRows]);
|
}, [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.
|
* Which orders the deliveries list already accounts for.
|
||||||
*
|
*
|
||||||
* The order row's own `deliveryid` is the direct answer, but it is only
|
* The order row's own `deliveryid` is the direct answer, and it is only
|
||||||
* populated for deliveries created after this backend ships — nothing wrote
|
* populated for deliveries created since the backend started writing it —
|
||||||
* `orders.deliveryid` before, and the orders query aliased the column away.
|
* nothing wrote `orders.deliveryid` before, and the orders query aliased the
|
||||||
* Every delivery already in production sits against an order that still
|
* column away. Every delivery that predates that fix sits against an order
|
||||||
* reads 0, so without this the queue would offer to assign them all again.
|
* 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 assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
|
||||||
|
|
||||||
const queue = useMemo(() => awaitingRider(allOrders, assigned), [allOrders, assigned]);
|
|
||||||
|
|
||||||
const branchOf = useMemo(
|
const branchOf = useMemo(
|
||||||
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
||||||
[branches],
|
[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
|
* The selection is built from these rather than from every visible row, so
|
||||||
* box disabled, and handing every queue id to the selection meant "select
|
* "select all" only ever ticks what can actually be sent. Handing it every
|
||||||
* all" ticked it anyway: the box went grey-and-checked and the bar counted
|
* row instead ticked the disabled boxes too: they went grey-and-checked and
|
||||||
* six when only five could go. The drawer caught it and named it, which is
|
* the bar counted more than it would assign, which is how a person stops
|
||||||
* the safety net working, but a count that disagrees with what happens is
|
* trusting the ticks.
|
||||||
* how a person stops trusting the ticks.
|
|
||||||
*/
|
*/
|
||||||
const selectable = useMemo(
|
const selectable = useMemo(
|
||||||
() =>
|
() =>
|
||||||
queue
|
orderRows
|
||||||
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
||||||
.map((row) => row.orderheaderid),
|
.map((row) => row.orderheaderid),
|
||||||
[queue, branchOf, assigned],
|
[orderRows, branchOf, assigned],
|
||||||
);
|
);
|
||||||
|
|
||||||
const picked = useSelection(selectable);
|
const picked = useSelection(selectable);
|
||||||
const [isAssigning, setAssigning] = useState(false);
|
|
||||||
|
|
||||||
const pickedOrders = useMemo(
|
const pickedOrders = useMemo(
|
||||||
() => queue.filter((row) => picked.has(row.orderheaderid)),
|
() => orderRows.filter((row) => picked.has(row.orderheaderid)),
|
||||||
[queue, picked],
|
[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 (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -276,18 +254,6 @@ export function SalesPage() {
|
|||||||
isActive={tab === 'orders'}
|
isActive={tab === 'orders'}
|
||||||
onClick={() => setTab('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
|
<TabButton
|
||||||
label="Deliveries"
|
label="Deliveries"
|
||||||
icon={<Truck size={14} />}
|
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 ──────────────────────────────────────────────────────── */}
|
{/* ── Orders ──────────────────────────────────────────────────────── */}
|
||||||
{tab === 'orders' ? (
|
{tab === 'orders' ? (
|
||||||
<>
|
<>
|
||||||
@@ -361,14 +310,35 @@ export function SalesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader
|
<FilterRow
|
||||||
title="Orders"
|
status={status}
|
||||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
|
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
|
<OrdersTable
|
||||||
rows={orderRows}
|
rows={orderRows}
|
||||||
isLoading={orders.isLoading}
|
isLoading={orders.isLoading}
|
||||||
showBranch={selected === null}
|
showBranch={selected === null}
|
||||||
|
selection={picked}
|
||||||
|
branchOf={branchOf}
|
||||||
|
assigned={assigned}
|
||||||
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
||||||
/>
|
/>
|
||||||
{orderRows.length > 0 ? (
|
{orderRows.length > 0 ? (
|
||||||
@@ -386,71 +356,6 @@ export function SalesPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : 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 ──────────────────────────────────────────────────── */}
|
{/* ── Deliveries ──────────────────────────────────────────────────── */}
|
||||||
{tab === 'deliveries' ? (
|
{tab === 'deliveries' ? (
|
||||||
@@ -487,9 +392,13 @@ export function SalesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader
|
<FilterRow
|
||||||
title="Deliveries"
|
status={status}
|
||||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
|
counts={tabCounts}
|
||||||
|
onStatus={setStatus}
|
||||||
|
search={
|
||||||
|
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DeliveriesTable
|
<DeliveriesTable
|
||||||
rows={deliveryRows}
|
rows={deliveryRows}
|
||||||
@@ -549,12 +458,11 @@ export function SalesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader
|
{/* No status strip here: a counter bill is rung and done, so it
|
||||||
title="Counter bills"
|
has no lifecycle to filter by. The search keeps the row. */}
|
||||||
action={
|
<HStack justify="end" wrap="wrap">
|
||||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
||||||
}
|
</HStack>
|
||||||
/>
|
|
||||||
<BillsTable
|
<BillsTable
|
||||||
rows={billRows}
|
rows={billRows}
|
||||||
isLoading={billPages.some((page) => page.isLoading)}
|
isLoading={billPages.some((page) => page.isLoading)}
|
||||||
@@ -573,15 +481,6 @@ export function SalesPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
{/* 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
|
used as captured, because the drawer can now CHANGE the row it is
|
||||||
@@ -620,23 +519,51 @@ function freshRow(
|
|||||||
const id = detail.row.orderheaderid;
|
const id = detail.row.orderheaderid;
|
||||||
return orders.find((row) => row.orderheaderid === id) ?? detail.row;
|
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 ────────────────────────────────────────────────── */
|
/* ── 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
|
* This row replaces the "Orders" / "Deliveries" headings that used to sit here.
|
||||||
* different question and therefore wants different columns. Money and item
|
* They named the table the tab had already named — two words of chrome between
|
||||||
* counts matter when you are reading a day's trading; when you are deciding who
|
* the numbers and the rows — while the filters sat above the KPI cards, so the
|
||||||
* takes what next, only three things do — where it is going, how long it has
|
* control and the list it narrowed were separated by four cards.
|
||||||
* been sitting there, and whether it can be sent at all.
|
*
|
||||||
|
* 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,
|
rows,
|
||||||
isLoading,
|
isLoading,
|
||||||
showBranch,
|
showBranch,
|
||||||
@@ -652,158 +579,6 @@ function QueueTable({
|
|||||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||||
assigned: ReadonlySet<number>;
|
assigned: ReadonlySet<number>;
|
||||||
onOpen: (row: OrderRow) => void;
|
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 (isLoading) return <Loading what="orders" />;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -822,6 +597,17 @@ function OrdersTable({
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>#</Th>
|
||||||
<Th>Order</Th>
|
<Th>Order</Th>
|
||||||
{showBranch ? <Th>Branch</Th> : null}
|
{showBranch ? <Th>Branch</Th> : null}
|
||||||
@@ -840,8 +626,36 @@ function OrdersTable({
|
|||||||
{rows.map((row, index) => {
|
{rows.map((row, index) => {
|
||||||
const cod = row.collectionamt ?? 0;
|
const cod = row.collectionamt ?? 0;
|
||||||
const amount = orderValue(row);
|
const amount = orderValue(row);
|
||||||
|
const verdict = assignability(row, branchOf(row), assigned);
|
||||||
|
const isPicked = selection.has(row.orderheaderid);
|
||||||
return (
|
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>
|
<Td isMuted>
|
||||||
<Mono>{index + 1}</Mono>
|
<Mono>{index + 1}</Mono>
|
||||||
</Td>
|
</Td>
|
||||||
@@ -1512,7 +1326,16 @@ const ViewHint = () => (
|
|||||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-brand)' }}>View →</span>
|
<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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -1524,7 +1347,10 @@ function Row({ children, onClick }: { children: React.ReactNode; onClick: () =>
|
|||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
...(isPicked ? { background: 'var(--color-brand-tint)' } : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,176 +1,65 @@
|
|||||||
/* ══ Assigning riders ══════════════════════════════════════════════════════
|
/* ══ Assigning riders ══════════════════════════════════════════════════════
|
||||||
Built on the site's tokens and on the Console's .panel/.pill vocabulary, so
|
Assignment lives in the Orders table, not on a page of its own — so this is
|
||||||
the drawer and the queue read as the same product as everything around them.
|
a tick column and a toolbar, and nothing more. Built on the site's tokens so
|
||||||
Only what is particular to assignment is defined here. */
|
the bar reads as part of the same table it sits above. */
|
||||||
|
|
||||||
/* ── The orders in the drawer ───────────────────────────────────────────── */
|
/* ── The tick column ────────────────────────────────────────────────────── */
|
||||||
/* A list, not a table: this is a confirmation of what is about to be handed
|
/* First column of the table, which is where the old console put it and where a
|
||||||
over, and the columns of the queue behind it would only be repeated here. */
|
person looks for it. Fixed width so the header box and the row boxes line up
|
||||||
.assign-list {
|
whatever the rest of the row does. */
|
||||||
display: flex;
|
.tick-cell {
|
||||||
flex-direction: column;
|
width: 38px;
|
||||||
border: 1px solid var(--color-line);
|
padding-left: 12px;
|
||||||
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;
|
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 ───────────────────────────────────── */
|
/* ── The assign bar ─────────────────────────────────────────────────────── */
|
||||||
/* How long an order has sat unassigned is the one fact that decides what an
|
/* Above the table, appearing when rows are ticked and staying afterwards to
|
||||||
operator does next, so it is not left as a timestamp to subtract. Amber past
|
report what happened — `createdeliveries` answers with a message and no ids,
|
||||||
fifteen minutes, red past thirty — a shopper who ordered half an hour ago and
|
so this line is the only record of the result. */
|
||||||
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 {
|
.assign-bar {
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--color-brand);
|
border: 1px solid var(--color-brand);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--color-brand-tint);
|
background: var(--color-brand-tint);
|
||||||
box-shadow: 0 -6px 20px -12px rgb(15 23 42 / 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assign-bar-count {
|
.assign-bar-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font: 600 13px/1.3 var(--font-sans);
|
font: 600 13px/1.3 var(--font-sans);
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tick column. Its own cell so clicking a box never opens the row. */
|
/* Wide enough for "Meera Raj · Scooter · TN 37 AB 4410" without the name being
|
||||||
.tick-cell { width: 34px; padding-left: 12px; }
|
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 ─────────────────────────────────────────── */
|
/* ── Recording progress by hand ─────────────────────────────────────────── */
|
||||||
/* Three plain buttons rather than a dropdown-and-save. Each is one decision
|
/* Three plain buttons rather than a dropdown-and-save. Each is one decision the
|
||||||
the operator has already made before opening the drawer; a two-step control
|
operator has already made before opening the drawer; a two-step control would
|
||||||
would add a confirmation to a correction that is itself reversible. */
|
add a confirmation to a correction that is itself reversible. */
|
||||||
.progress-moves { display: flex; gap: 6px; flex-wrap: wrap; }
|
.progress-moves { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.progress-move {
|
.progress-move {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
|||||||
Reference in New Issue
Block a user