deliveries
This commit is contained in:
91
src/api/deliveries.ts
Normal file
91
src/api/deliveries.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { api, WEB } from './client';
|
||||||
|
import type { RiderInfo } from './types';
|
||||||
|
import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliveries — creating them, moving them along, and finding a rider.
|
||||||
|
*
|
||||||
|
* Separate from `insights.ts`, which only READS deliveries. The split is the
|
||||||
|
* same one the backend makes: `getdeliveries` answers "what is out there", and
|
||||||
|
* these three change it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RiderQuery {
|
||||||
|
/**
|
||||||
|
* The delivery region. This is the scope that works.
|
||||||
|
*
|
||||||
|
* `tenantid` is also accepted and returns nothing: the filter is
|
||||||
|
* `app_users.tenantid`, which is not set on rider accounts. Verified against
|
||||||
|
* production — `?tenantid=1135` gives an empty list while `?applocationid=1`
|
||||||
|
* gives the rider working that tenant's shops. Passing the tenant would have
|
||||||
|
* produced an empty picker with no error to explain it.
|
||||||
|
*/
|
||||||
|
applocationid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deliveriesApi = {
|
||||||
|
/**
|
||||||
|
* Riders on duty right now.
|
||||||
|
*
|
||||||
|
* "On duty" is the backend's word, not a filter added here: the query wants
|
||||||
|
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
|
||||||
|
* `logstatus = 0`. So this list empties overnight and refills as riders clock
|
||||||
|
* on, and an empty answer means nobody has started their shift — not that
|
||||||
|
* the shop has no riders. The picker has to say which.
|
||||||
|
*/
|
||||||
|
riders: (query: RiderQuery) =>
|
||||||
|
api.list<RiderInfo>(`${WEB}/partners/getriders`, { applocationid: query.applocationid }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand orders to a rider.
|
||||||
|
*
|
||||||
|
* An array, always, because that is what the endpoint takes and because one
|
||||||
|
* call is one transaction: each row inserts a `deliveries` row, copies it to
|
||||||
|
* `deliveryqueues` for the rider's app, and moves the parent order's status.
|
||||||
|
* Verified with three orders in a single call — three deliveries, three
|
||||||
|
* queue rows, nothing duplicated.
|
||||||
|
*
|
||||||
|
* The response carries no ids, only a message, so callers refetch rather
|
||||||
|
* than patching a row in place.
|
||||||
|
*/
|
||||||
|
assign: (rows: DeliveryDraft[]) =>
|
||||||
|
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a delivery along its ladder, or hand it to a different rider.
|
||||||
|
*
|
||||||
|
* `deliveryid` is the only field the backend insists on — it finds the row
|
||||||
|
* with it and derives the parent order from that rather than trusting the
|
||||||
|
* caller's `orderheaderid`, which is why a partial payload is safe here.
|
||||||
|
*
|
||||||
|
* Note what the backend does NOT do: `picked` updates the delivery and stops
|
||||||
|
* there, leaving the order at its previous status. Only pending, delivered
|
||||||
|
* and cancelled are mirrored onto the order.
|
||||||
|
*/
|
||||||
|
update: (body: UpdateDelivery) =>
|
||||||
|
api.put<unknown>(`${WEB}/deliveries/updatedelivery`, body),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delivery lifecycle, lowercase, as `deliveries.orderstatus` stores it.
|
||||||
|
*
|
||||||
|
* `skipped` is real and reachable — the rider got there and nobody was in —
|
||||||
|
* but it is written by the rider's app, not from here, so it is not offered.
|
||||||
|
*/
|
||||||
|
export const DELIVERY_STEPS = ['pending', 'accepted', 'arrived', 'picked', 'active', 'delivered'] as const;
|
||||||
|
export type DeliveryStep = (typeof DELIVERY_STEPS)[number];
|
||||||
|
|
||||||
|
export interface UpdateDelivery {
|
||||||
|
deliveryid: number;
|
||||||
|
orderstatus: string;
|
||||||
|
/** Sent when known so the backend does not have to look it up. */
|
||||||
|
orderheaderid?: number;
|
||||||
|
/** Set to move the job to a different rider. */
|
||||||
|
userid?: number;
|
||||||
|
assigntime?: string;
|
||||||
|
starttime?: string;
|
||||||
|
arrivaltime?: string;
|
||||||
|
pickuptime?: string;
|
||||||
|
deliverytime?: string;
|
||||||
|
canceltime?: string;
|
||||||
|
}
|
||||||
@@ -441,7 +441,14 @@ export interface OrderRow {
|
|||||||
pickupcontactno?: string;
|
pickupcontactno?: string;
|
||||||
pickupaddress?: string;
|
pickupaddress?: string;
|
||||||
pickupsuburb?: string;
|
pickupsuburb?: string;
|
||||||
/** The delivery half. Blank on an order nobody has been assigned to. */
|
/**
|
||||||
|
* The delivery half. Blank on an order nobody has been assigned to.
|
||||||
|
*
|
||||||
|
* `deliveryid` is the one to read for "has a rider been assigned yet" — it is
|
||||||
|
* 0 until `createdeliveries` runs, and it is 0 on every unassigned row in
|
||||||
|
* production. `rider` looks like the same test and is not: it is filled from
|
||||||
|
* a join and is empty on rows that DO have a delivery.
|
||||||
|
*/
|
||||||
deliveryid?: number;
|
deliveryid?: number;
|
||||||
rider?: string;
|
rider?: string;
|
||||||
ridercontactno?: string;
|
ridercontactno?: string;
|
||||||
@@ -450,6 +457,77 @@ export interface OrderRow {
|
|||||||
pickuptime?: string;
|
pickuptime?: string;
|
||||||
deliverytime?: string;
|
deliverytime?: string;
|
||||||
canceltime?: string;
|
canceltime?: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The ids a delivery row has to be built from.
|
||||||
|
*
|
||||||
|
* All of these are on the wire and none were typed, because nothing read
|
||||||
|
* them until assignment existed. `createdeliveries` copies them onto the
|
||||||
|
* `deliveries` row, and the reads that follow join on them — so an order
|
||||||
|
* that arrives without one produces a delivery that cannot be seen. See
|
||||||
|
* `assignDelivery.ts` for which of them are load-bearing.
|
||||||
|
*/
|
||||||
|
applocationid?: number;
|
||||||
|
partnerid?: number;
|
||||||
|
configid?: number;
|
||||||
|
moduleid?: number;
|
||||||
|
categoryid?: number;
|
||||||
|
subcategoryid?: number;
|
||||||
|
/** Who placed the order. Distinct from `deliverycustomerid`, which is 0. */
|
||||||
|
customerid?: number;
|
||||||
|
deliverycustomerid?: number;
|
||||||
|
deliverylocationid?: number;
|
||||||
|
pickuplocationid?: number;
|
||||||
|
pickuplat?: string;
|
||||||
|
pickuplong?: string;
|
||||||
|
deliverylat?: string;
|
||||||
|
deliverylong?: string;
|
||||||
|
droplat?: string;
|
||||||
|
droplon?: string;
|
||||||
|
kms?: string;
|
||||||
|
/**
|
||||||
|
* Empty on every production order, so it CANNOT be used to tell a delivery
|
||||||
|
* order from a collection. Typed only so nobody reaches for it and quietly
|
||||||
|
* filters the whole list away — the drop address is the real test.
|
||||||
|
*/
|
||||||
|
deliverytype?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rider, from `GET /partners/getriders` (`models.RiderInfo`).
|
||||||
|
*
|
||||||
|
* Only riders who are ON DUTY TODAY appear. The query requires a `riderlogs`
|
||||||
|
* row stamped today with `logstatus = 0` and `app_userpools.onduty = 1`, so an
|
||||||
|
* empty list means "nobody has clocked on", not "this shop has no riders" —
|
||||||
|
* a distinction the picker has to make, or an operator spends the morning
|
||||||
|
* wondering why the fleet vanished.
|
||||||
|
*
|
||||||
|
* `password` is deliberately absent for the same reason it is on `Staff`: the
|
||||||
|
* endpoint returns it in clear, and not typing it is what stops a cell
|
||||||
|
* rendering one.
|
||||||
|
*/
|
||||||
|
export interface RiderInfo {
|
||||||
|
userid: number;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
fullname?: string;
|
||||||
|
contactno?: string;
|
||||||
|
partnerid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
applocation?: string;
|
||||||
|
vehiclename?: string;
|
||||||
|
vehicleno?: string;
|
||||||
|
licenseno?: string;
|
||||||
|
shiftid?: number;
|
||||||
|
/** Shift window, as `HH:MM:SS`. */
|
||||||
|
starttime?: string;
|
||||||
|
endtime?: string;
|
||||||
|
/** Today's log. `logstatus` 0 is on duty. */
|
||||||
|
logdate?: string;
|
||||||
|
login?: string;
|
||||||
|
logout?: string;
|
||||||
|
logstatus?: number;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
291
src/features/store-admin/AssignRiderDrawer.tsx
Normal file
291
src/features/store-admin/AssignRiderDrawer.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { AlertTriangle, Bike, Check, MapPin, Phone } from 'lucide-react';
|
||||||
|
import { errorMessage } from '@/api/client';
|
||||||
|
import { deliveriesApi } from '@/api/deliveries';
|
||||||
|
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||||
|
import { useRiders } from '@/queries/hooks';
|
||||||
|
import { queryKeys } from '@/queries/keys';
|
||||||
|
import { Drawer } from './Drawer';
|
||||||
|
import {
|
||||||
|
assignability,
|
||||||
|
buildDeliveries,
|
||||||
|
riderName,
|
||||||
|
riderVehicle,
|
||||||
|
waitingMs,
|
||||||
|
} from './assignDelivery';
|
||||||
|
import { money } from './format';
|
||||||
|
import { shortAge } from './posStatus';
|
||||||
|
import { orderValue } from './orderStatus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handing orders to a rider.
|
||||||
|
*
|
||||||
|
* One drawer for one order and for twenty, because the call underneath is the
|
||||||
|
* same either way: `createdeliveries` takes an array and writes it in one
|
||||||
|
* transaction. Building a single-order path as well would be two code paths
|
||||||
|
* for one endpoint, and the second one is always the one that rots.
|
||||||
|
*
|
||||||
|
* ── What this screen is careful about ───────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Assigning is an INSERT, not a status change, and the row it inserts is what
|
||||||
|
* every later read joins on. An order whose branch has no delivery region set
|
||||||
|
* produces a job that saves cleanly and then cannot be seen by anybody — so
|
||||||
|
* those are held back here, named, and left in the queue rather than sent and
|
||||||
|
* quietly lost. `assignDelivery.ts` holds the rules; this shows their answers.
|
||||||
|
*/
|
||||||
|
export interface AssignRiderDrawerProps {
|
||||||
|
orders: OrderRow[];
|
||||||
|
/** The branch each order belongs to, for the pickup end and the region. */
|
||||||
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||||
|
/**
|
||||||
|
* Orders the deliveries list already accounts for. Covers the rows assigned
|
||||||
|
* before the backend started writing `orders.deliveryid`.
|
||||||
|
*/
|
||||||
|
assigned?: ReadonlySet<number>;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called once at least one job was created, so the caller can clear its ticks. */
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignRiderDrawer({
|
||||||
|
orders,
|
||||||
|
branchOf,
|
||||||
|
assigned,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: AssignRiderDrawerProps) {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [chosen, setChosen] = useState<number | null>(null);
|
||||||
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Every order in a batch shares a region in practice — they are the same
|
||||||
|
// branch's orders — so the first one that has a region decides which fleet
|
||||||
|
// to ask for.
|
||||||
|
const region =
|
||||||
|
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
|
||||||
|
|
||||||
|
const riders = useRiders(region || undefined);
|
||||||
|
const fleet = riders.data ?? [];
|
||||||
|
|
||||||
|
const ready = orders.filter((row) => assignability(row, branchOf(row), assigned).canAssign);
|
||||||
|
const held = orders
|
||||||
|
.map((row) => ({ row, reason: assignability(row, branchOf(row), assigned).reason }))
|
||||||
|
.filter((entry) => entry.reason !== '');
|
||||||
|
|
||||||
|
const rider = fleet.find((entry) => entry.userid === chosen);
|
||||||
|
|
||||||
|
const send = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!rider) throw new Error('Pick a rider first');
|
||||||
|
const { drafts } = buildDeliveries(ready, rider, branchOf, new Date(), assigned);
|
||||||
|
if (drafts.length === 0) throw new Error('None of these orders can be assigned');
|
||||||
|
return deliveriesApi.assign(drafts);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
// The response carries a message and no ids, so both lists are refetched
|
||||||
|
// rather than patched: the orders now have a deliveryid and the
|
||||||
|
// deliveries page has rows it did not have a moment ago.
|
||||||
|
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
||||||
|
onDone();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => setProblem(errorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={ready.length === 1 ? 'Assign a rider' : `Assign ${ready.length} orders`}
|
||||||
|
subtitle={
|
||||||
|
ready.length === 1
|
||||||
|
? ready[0]?.orderid || `#${ready[0]?.orderheaderid}`
|
||||||
|
: 'They all go to the same rider'
|
||||||
|
}
|
||||||
|
width={480}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<VStack gap={3}>
|
||||||
|
{/* ── What is being handed over ───────────────────────────────── */}
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label" size="xsm" color="secondary">
|
||||||
|
{ready.length === 1 ? 'This order' : `These ${ready.length} orders`}
|
||||||
|
</Text>
|
||||||
|
<div className="assign-list">
|
||||||
|
{ready.map((row) => (
|
||||||
|
<div key={row.orderheaderid} className="assign-row">
|
||||||
|
<div className="assign-row-main">
|
||||||
|
<strong>{row.deliverycustomer || 'Customer'}</strong>
|
||||||
|
<span>{row.deliveryaddress || row.deliverysuburb || 'No address'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="assign-row-side">
|
||||||
|
<strong>{money(orderValue(row))}</strong>
|
||||||
|
<span>{ageLabel(row)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Orders that came in with the selection but cannot be sent. Named
|
||||||
|
rather than silently dropped — the operator has to know one is
|
||||||
|
still sitting there, and why. */}
|
||||||
|
{held.length > 0 ? (
|
||||||
|
<div className="assign-held">
|
||||||
|
<HStack gap={1} align="start">
|
||||||
|
<AlertTriangle size={15} />
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text type="body" size="sm">
|
||||||
|
{held.length === 1
|
||||||
|
? 'One order is not going with this batch'
|
||||||
|
: `${held.length} orders are not going with this batch`}
|
||||||
|
</Text>
|
||||||
|
{held.map(({ row, reason }) => (
|
||||||
|
<Text key={row.orderheaderid} type="body" size="xsm" color="secondary">
|
||||||
|
{row.orderid || `#${row.orderheaderid}`} — {reason}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── The fleet ───────────────────────────────────────────────── */}
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label" size="xsm" color="secondary">
|
||||||
|
Riders on duty
|
||||||
|
</Text>
|
||||||
|
<RiderPicker
|
||||||
|
riders={fleet}
|
||||||
|
isLoading={riders.isLoading}
|
||||||
|
hasRegion={region !== 0}
|
||||||
|
chosen={chosen}
|
||||||
|
onChoose={(id) => {
|
||||||
|
setChosen(id);
|
||||||
|
setProblem(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{problem ? (
|
||||||
|
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||||
|
{problem}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||||
|
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
|
||||||
|
<Button
|
||||||
|
label={send.isPending ? 'Assigning…' : 'Assign'}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Bike size={14} />}
|
||||||
|
isDisabled={!rider || ready.length === 0 || send.isPending}
|
||||||
|
onClick={() => send.mutate()}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text type="body" size="xsm" color="secondary">
|
||||||
|
The rider sees the job in their app straight away, and the order moves onto the
|
||||||
|
deliveries list.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rider takes it.
|
||||||
|
*
|
||||||
|
* The three empty states are different problems with different fixes, so they
|
||||||
|
* say different things. "Nobody has clocked on" is a shift that has not
|
||||||
|
* started; "no delivery region" is a branch someone has to configure; and a
|
||||||
|
* fleet of one is neither — it just looks like a bug if the list is silent
|
||||||
|
* about it.
|
||||||
|
*/
|
||||||
|
function RiderPicker({
|
||||||
|
riders,
|
||||||
|
isLoading,
|
||||||
|
hasRegion,
|
||||||
|
chosen,
|
||||||
|
onChoose,
|
||||||
|
}: {
|
||||||
|
riders: RiderInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
hasRegion: boolean;
|
||||||
|
chosen: number | null;
|
||||||
|
onChoose: (userid: number) => void;
|
||||||
|
}) {
|
||||||
|
if (!hasRegion) {
|
||||||
|
return (
|
||||||
|
<div className="assign-empty">
|
||||||
|
<MapPin size={16} />
|
||||||
|
<Text type="body" size="sm" color="secondary">
|
||||||
|
These orders have no delivery region, so there is no fleet to ask. Set the region on the
|
||||||
|
branch first.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="assign-empty">
|
||||||
|
<Text type="body" size="sm" color="secondary">
|
||||||
|
Finding riders…
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="assign-empty">
|
||||||
|
<Bike size={16} />
|
||||||
|
<Text type="body" size="sm" color="secondary">
|
||||||
|
No rider has clocked on today. The list fills as riders start their shift — nothing is
|
||||||
|
wrong with the orders.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rider-list">
|
||||||
|
{riders.map((rider) => {
|
||||||
|
const isChosen = rider.userid === chosen;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={rider.userid}
|
||||||
|
type="button"
|
||||||
|
className="rider-option"
|
||||||
|
data-chosen={isChosen}
|
||||||
|
aria-pressed={isChosen}
|
||||||
|
onClick={() => onChoose(rider.userid)}
|
||||||
|
>
|
||||||
|
<span className="rider-mark">{isChosen ? <Check size={13} /> : <Bike size={13} />}</span>
|
||||||
|
<span className="rider-text">
|
||||||
|
<strong>{riderName(rider)}</strong>
|
||||||
|
<span>{riderVehicle(rider) || 'No vehicle recorded'}</span>
|
||||||
|
</span>
|
||||||
|
{rider.contactno ? (
|
||||||
|
<span className="rider-contact">
|
||||||
|
<Phone size={11} />
|
||||||
|
{rider.contactno}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long this one has been waiting. Blank rather than "0m" when undatable. */
|
||||||
|
function ageLabel(row: OrderRow): string {
|
||||||
|
const waited = waitingMs(row);
|
||||||
|
return waited === null ? '' : `waiting ${shortAge(waited)}`;
|
||||||
|
}
|
||||||
101
src/features/store-admin/DeliveryProgress.tsx
Normal file
101
src/features/store-admin/DeliveryProgress.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { errorMessage } from '@/api/client';
|
||||||
|
import { deliveriesApi } from '@/api/deliveries';
|
||||||
|
import type { DeliveryRow } from '@/api/types';
|
||||||
|
import { queryKeys } from '@/queries/keys';
|
||||||
|
import { stampNow } from './assignDelivery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving a delivery along from the back office.
|
||||||
|
*
|
||||||
|
* The rider's app drives this ladder in the ordinary case. This is for when it
|
||||||
|
* cannot: a rider whose phone died, a job cancelled by phone, a delivery that
|
||||||
|
* was completed but never marked. Without it the only way to correct a stuck
|
||||||
|
* job is a database write.
|
||||||
|
*
|
||||||
|
* ── Two things the backend does that shape this ─────────────────────────────
|
||||||
|
*
|
||||||
|
* `updatedelivery` mirrors the status onto the parent order for `pending`,
|
||||||
|
* `delivered` and `cancelled` — but NOT for `picked`, which updates the
|
||||||
|
* delivery and leaves the order where it was. So the order and its delivery
|
||||||
|
* legitimately disagree in the middle of a journey, and this does not try to
|
||||||
|
* paper over that.
|
||||||
|
*
|
||||||
|
* The lifecycle timestamp is sent explicitly. `Updates()` skips zero-valued
|
||||||
|
* struct fields, so a status sent without its stamp moved the status and left
|
||||||
|
* the column blank — which is how a delivery ends up marked delivered with no
|
||||||
|
* delivery time against it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What can be set from here, in the order a job actually walks. */
|
||||||
|
const MOVES = [
|
||||||
|
{ to: 'picked', label: 'Picked up', stamp: 'pickuptime' },
|
||||||
|
{ to: 'delivered', label: 'Delivered', stamp: 'deliverytime' },
|
||||||
|
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** The end states. Nothing follows them, so nothing is offered. */
|
||||||
|
const SETTLED = ['delivered', 'cancelled'];
|
||||||
|
|
||||||
|
export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
||||||
|
const isSettled = SETTLED.includes(status);
|
||||||
|
|
||||||
|
const move = useMutation({
|
||||||
|
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
||||||
|
deliveriesApi.update({
|
||||||
|
deliveryid: job.deliveryid,
|
||||||
|
orderheaderid: job.orderheaderid ?? 0,
|
||||||
|
orderstatus: to,
|
||||||
|
[stamp]: stampNow(),
|
||||||
|
}),
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.insights.all }),
|
||||||
|
onError: (error) => setProblem(errorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSettled) {
|
||||||
|
return (
|
||||||
|
<Text type="body" size="xsm" color="secondary">
|
||||||
|
This job is {status}. Nothing further to record.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={1}>
|
||||||
|
<div className="progress-moves">
|
||||||
|
{MOVES.map((step) => (
|
||||||
|
<button
|
||||||
|
key={step.to}
|
||||||
|
type="button"
|
||||||
|
className="progress-move"
|
||||||
|
data-danger={step.to === 'cancelled'}
|
||||||
|
disabled={move.isPending || step.to === status}
|
||||||
|
onClick={() => {
|
||||||
|
setProblem(null);
|
||||||
|
move.mutate({ to: step.to, stamp: step.stamp });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{problem ? (
|
||||||
|
<Text type="body" size="xsm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||||
|
{problem}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="body" size="xsm" color="secondary">
|
||||||
|
Use these only when the rider's app has not. Marking a job delivered here also closes the
|
||||||
|
order.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text';
|
|||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
import { Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||||
import type { DeliveryRow, OrderRow } from '@/api/types';
|
import type { DeliveryRow, OrderRow } from '@/api/types';
|
||||||
|
import { DeliveryProgress } from './DeliveryProgress';
|
||||||
import { branchLabel, moneyExact } from './format';
|
import { branchLabel, moneyExact } from './format';
|
||||||
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
||||||
|
|
||||||
@@ -268,6 +269,11 @@ export function OrderDetailDrawer({
|
|||||||
No rider assigned yet.
|
No rider assigned yet.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{/* Recording progress by hand. Under the rider rather than beside
|
||||||
|
the status chip at the top: it is a correction, not the normal
|
||||||
|
way a job moves, and putting it in the header would invite it
|
||||||
|
to be used as one. */}
|
||||||
|
<DeliveryProgress job={job} />
|
||||||
</VStack>
|
</VStack>
|
||||||
) : order?.rider ? (
|
) : order?.rider ? (
|
||||||
<VStack gap={1}>
|
<VStack gap={1}>
|
||||||
|
|||||||
293
src/features/store-admin/assignDelivery.test.ts
Normal file
293
src/features/store-admin/assignDelivery.test.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||||
|
import {
|
||||||
|
DELIVERY_CREATED_STATUS,
|
||||||
|
assignability,
|
||||||
|
assignedFrom,
|
||||||
|
awaitingRider,
|
||||||
|
buildDeliveries,
|
||||||
|
buildDelivery,
|
||||||
|
isUnassigned,
|
||||||
|
needsDelivery,
|
||||||
|
riderName,
|
||||||
|
riderVehicle,
|
||||||
|
stampNow,
|
||||||
|
waitingMs,
|
||||||
|
} from './assignDelivery';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Assigning a rider is an INSERT, not a status change, and every read that follows
|
||||||
|
joins on the row it writes. So these tests are mostly about the payload: a
|
||||||
|
delivery created with the wrong word or a missing id saves cleanly, reports
|
||||||
|
success, and then cannot be seen by anybody.
|
||||||
|
|
||||||
|
The fixture is a real production order — Suriya Store 1135-379, verified on
|
||||||
|
2026-09-03 — rather than an invented one, because the fields that turned out to
|
||||||
|
matter are exactly the ones a plausible fixture would have got wrong.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const order = (over: Partial<OrderRow> = {}): OrderRow => ({
|
||||||
|
orderheaderid: 150158,
|
||||||
|
orderid: '1135-379',
|
||||||
|
tenantid: 1135,
|
||||||
|
locationid: 1166,
|
||||||
|
applocationid: 1,
|
||||||
|
partnerid: 60,
|
||||||
|
configid: 1,
|
||||||
|
moduleid: 2,
|
||||||
|
categoryid: 2,
|
||||||
|
subcategoryid: 0,
|
||||||
|
orderdate: '2026-09-03T16:16:39+05:30',
|
||||||
|
orderstatus: 'created',
|
||||||
|
itemcount: 1,
|
||||||
|
orderamount: 80,
|
||||||
|
ordervalue: 80,
|
||||||
|
paymenttype: 42,
|
||||||
|
customerid: 6060,
|
||||||
|
deliveryid: 0,
|
||||||
|
deliverycustomer: 'anbu',
|
||||||
|
deliverycontactno: '7397177923',
|
||||||
|
deliveryaddress: 'Harmony Arcade, R.S. Puram, Coimbatore',
|
||||||
|
deliverylat: '11.0050853',
|
||||||
|
deliverylong: '76.9508624',
|
||||||
|
pickupcustomer: 'Ragul Stores',
|
||||||
|
pickupcontactno: '7402223869',
|
||||||
|
pickupaddress: '412-419, R.S. Puram, Coimbatore',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rider: RiderInfo = {
|
||||||
|
userid: 897,
|
||||||
|
firstname: 'Varun',
|
||||||
|
lastname: 'Edward',
|
||||||
|
fullname: 'Varun Edward',
|
||||||
|
contactno: '8248136667',
|
||||||
|
vehiclename: 'Bike',
|
||||||
|
vehicleno: 'TN 38CV 1535',
|
||||||
|
};
|
||||||
|
|
||||||
|
const branch: TenantLocation = {
|
||||||
|
locationid: 1166,
|
||||||
|
tenantid: 1135,
|
||||||
|
applocationid: 1,
|
||||||
|
locationname: 'Suriya Store RS Puram',
|
||||||
|
status: 'Active',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Which orders are waiting ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('unassigned is read from deliveryid, not from the rider name', () => {
|
||||||
|
assert.equal(isUnassigned(order({ deliveryid: 0 })), true);
|
||||||
|
assert.equal(isUnassigned(order({ deliveryid: undefined })), true);
|
||||||
|
assert.equal(isUnassigned(order({ deliveryid: 128302 })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an order the deliveries list already holds is not offered again', () => {
|
||||||
|
/*
|
||||||
|
`orders.deliveryid` was never written and the orders query aliased the column
|
||||||
|
away, so every delivery that existed before this work sits against an order
|
||||||
|
whose deliveryid still reads 0. Both are fixed, but only for new rows —
|
||||||
|
without the deliveries list as a second signal, the queue would offer to
|
||||||
|
assign production's entire delivery history a second time.
|
||||||
|
*/
|
||||||
|
const already = assignedFrom([{ orderheaderid: 150158 }, { orderheaderid: 999 }]);
|
||||||
|
assert.equal(isUnassigned(order({ deliveryid: 0 }), already), false);
|
||||||
|
assert.equal(isUnassigned(order({ orderheaderid: 777, deliveryid: 0 }), already), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the assigned set ignores deliveries with no order attached', () => {
|
||||||
|
const built = assignedFrom([{ orderheaderid: 5 }, { orderheaderid: 0 }, {}]);
|
||||||
|
assert.deepEqual([...built], [5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an assigned order is not offered again just because the rider name is blank', () => {
|
||||||
|
// `rider` comes from a join and is empty on plenty of rows that do have a
|
||||||
|
// delivery. Testing it would offer to assign jobs already out with somebody.
|
||||||
|
assert.equal(isUnassigned(order({ deliveryid: 128302, rider: '' })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the drop address decides what needs a rider, because deliverytype is always empty', () => {
|
||||||
|
assert.equal(needsDelivery(order({ deliverytype: '' })), true);
|
||||||
|
assert.equal(needsDelivery(order({ deliveryaddress: '', deliverycustomer: '' })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the queue keeps only unassigned delivery orders that are still live', () => {
|
||||||
|
const rows = [
|
||||||
|
order({ orderheaderid: 1 }),
|
||||||
|
order({ orderheaderid: 2, deliveryid: 999 }),
|
||||||
|
order({ orderheaderid: 3, orderstatus: 'delivered' }),
|
||||||
|
order({ orderheaderid: 4, orderstatus: 'cancelled' }),
|
||||||
|
order({ orderheaderid: 5, deliveryaddress: '', deliverycustomer: '' }),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
awaitingRider(rows).map((r) => r.orderheaderid),
|
||||||
|
[1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the queue puts the longest wait first, unlike every other list on the page', () => {
|
||||||
|
const rows = [
|
||||||
|
order({ orderheaderid: 2, orderdate: '2026-09-03T12:00:00+05:30' }),
|
||||||
|
order({ orderheaderid: 1, orderdate: '2026-09-03T09:00:00+05:30' }),
|
||||||
|
order({ orderheaderid: 3, orderdate: '2026-09-03T15:00:00+05:30' }),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
awaitingRider(rows).map((r) => r.orderheaderid),
|
||||||
|
[1, 2, 3],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waiting time is null when undatable, rather than claiming no wait', () => {
|
||||||
|
const now = new Date('2026-09-03T17:16:39+05:30').getTime();
|
||||||
|
assert.equal(waitingMs(order(), now), 3_600_000);
|
||||||
|
assert.equal(waitingMs(order({ orderdate: '' })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Whether it can be assigned at all ───────────────────────────────────── */
|
||||||
|
|
||||||
|
test('an ordinary unassigned order can be assigned', () => {
|
||||||
|
assert.equal(assignability(order(), branch).canAssign, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a branch with no delivery region is refused, because the job would be invisible', () => {
|
||||||
|
/*
|
||||||
|
The guard that matters most. `createdeliveries` inserts a zero applocationid
|
||||||
|
happily and reports success, then three of the reads that follow join
|
||||||
|
app_location and app_locationconfig on it — so the job exists, sits in the
|
||||||
|
rider's queue, and appears on no screen. Suriya Store NSN (location 1172)
|
||||||
|
carries applocationid 0 in production today, so this is not hypothetical.
|
||||||
|
*/
|
||||||
|
const verdict = assignability(order({ applocationid: 0 }), { ...branch, applocationid: 0 });
|
||||||
|
assert.equal(verdict.canAssign, false);
|
||||||
|
assert.match(verdict.reason, /region/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the region falls back to the branch when the order row is missing it', () => {
|
||||||
|
assert.equal(assignability(order({ applocationid: 0 }), branch).canAssign, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an order that already has a rider is refused', () => {
|
||||||
|
assert.equal(assignability(order({ deliveryid: 128302 }), branch).canAssign, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an order with no drop address is refused, and says so', () => {
|
||||||
|
const verdict = assignability(order({ deliveryaddress: '', deliverycustomer: '' }), branch);
|
||||||
|
assert.equal(verdict.canAssign, false);
|
||||||
|
assert.match(verdict.reason, /address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── The payload ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-03T17:30:00+05:30');
|
||||||
|
|
||||||
|
test('the job is created as lowercase pending, so it reaches the rider queue', () => {
|
||||||
|
/*
|
||||||
|
The rider's app reads `getdeliveryqueues`, which filters
|
||||||
|
WHERE orderstatus = 'pending' AND userid = ?. "Assigned" is the obvious word
|
||||||
|
to reach for — the ORDER ladder uses capitalised words — and it creates a job
|
||||||
|
the assigned rider can never see.
|
||||||
|
*/
|
||||||
|
assert.equal(buildDelivery(order(), rider, branch, NOW).orderstatus, 'pending');
|
||||||
|
assert.equal(DELIVERY_CREATED_STATUS, 'pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pickup address goes under its capitalised key', () => {
|
||||||
|
// `Pickupaddress`, alone among fifty lowercase JSON tags on models.Deliveries.
|
||||||
|
// Sending `pickupaddress` drops the address silently.
|
||||||
|
assert.equal(
|
||||||
|
buildDelivery(order(), rider, branch, NOW).Pickupaddress,
|
||||||
|
'412-419, R.S. Puram, Coimbatore',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every id is carried across from the order rather than defaulted', () => {
|
||||||
|
const draft = buildDelivery(order(), rider, branch, NOW);
|
||||||
|
assert.equal(draft.orderheaderid, 150158);
|
||||||
|
assert.equal(draft.tenantid, 1135);
|
||||||
|
assert.equal(draft.locationid, 1166);
|
||||||
|
assert.equal(draft.applocationid, 1);
|
||||||
|
assert.equal(draft.partnerid, 60);
|
||||||
|
assert.equal(draft.configid, 1);
|
||||||
|
assert.equal(draft.moduleid, 2);
|
||||||
|
assert.equal(draft.categoryid, 2);
|
||||||
|
assert.equal(draft.userid, 897);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the job says what is being carried, which the old rows left at zero', () => {
|
||||||
|
// Production deliveries sit at itemcount 0 and orderamount 0 against orders
|
||||||
|
// worth ₹80, so the deliveries page cannot tell a rider what they have.
|
||||||
|
const draft = buildDelivery(order(), rider, branch, NOW);
|
||||||
|
assert.equal(draft.itemcount, 1);
|
||||||
|
assert.equal(draft.orderamount, 80);
|
||||||
|
assert.equal(draft.deliveryamt, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('times are local wall-clock, not ISO instants', () => {
|
||||||
|
const draft = buildDelivery(order(), rider, branch, NOW);
|
||||||
|
assert.equal(draft.assigntime, '2026-09-03 17:30:00');
|
||||||
|
assert.equal(draft.deliverydate, '2026-09-03');
|
||||||
|
assert.equal(stampNow(new Date(2026, 0, 5, 9, 7, 3)), '2026-01-05 09:07:03');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the branch supplies a pickup end the order does not carry', () => {
|
||||||
|
const thin = buildDelivery(
|
||||||
|
order({ pickupcustomer: '', pickupaddress: '', pickupcontactno: '' }),
|
||||||
|
rider,
|
||||||
|
{ ...branch, address: '12, DB Road, RS Puram', contactno: '9876543212' },
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(thin.pickupcustomer, 'Suriya Store RS Puram');
|
||||||
|
assert.equal(thin.Pickupaddress, '12, DB Road, RS Puram');
|
||||||
|
assert.equal(thin.pickupcontactno, '9876543212');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Batches ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('a batch builds one row per order', () => {
|
||||||
|
const { drafts, skipped } = buildDeliveries(
|
||||||
|
[order({ orderheaderid: 1 }), order({ orderheaderid: 2 })],
|
||||||
|
rider,
|
||||||
|
() => branch,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
drafts.map((d) => d.orderheaderid),
|
||||||
|
[1, 2],
|
||||||
|
);
|
||||||
|
assert.deepEqual(skipped, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a batch drops what cannot be assigned instead of sending it with the rest', () => {
|
||||||
|
/*
|
||||||
|
The endpoint reports one outcome for the whole array, so one bad row takes
|
||||||
|
the good ones down with it. Dropping it and naming it is the only way the
|
||||||
|
operator learns which order still needs attention.
|
||||||
|
*/
|
||||||
|
const { drafts, skipped } = buildDeliveries(
|
||||||
|
[order({ orderheaderid: 1 }), order({ orderheaderid: 2, applocationid: 0 }), order({ orderheaderid: 3 })],
|
||||||
|
rider,
|
||||||
|
(row) => (row.orderheaderid === 2 ? { ...branch, applocationid: 0 } : branch),
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
drafts.map((d) => d.orderheaderid),
|
||||||
|
[1, 3],
|
||||||
|
);
|
||||||
|
assert.equal(skipped.length, 1);
|
||||||
|
assert.equal(skipped[0]?.row.orderheaderid, 2);
|
||||||
|
assert.match(skipped[0]?.reason ?? '', /region/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Labels ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('a rider always gets a readable label', () => {
|
||||||
|
assert.equal(riderName(rider), 'Varun Edward');
|
||||||
|
assert.equal(riderName({ userid: 1, firstname: 'Varun', lastname: 'Edward' }), 'Varun Edward');
|
||||||
|
assert.equal(riderName({ userid: 42 }), 'Rider 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the vehicle line is empty rather than a stray separator when nothing is recorded', () => {
|
||||||
|
assert.equal(riderVehicle(rider), 'Bike · TN 38CV 1535');
|
||||||
|
assert.equal(riderVehicle({ userid: 1 }), '');
|
||||||
|
});
|
||||||
345
src/features/store-admin/assignDelivery.ts
Normal file
345
src/features/store-admin/assignDelivery.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning an order into a delivery job.
|
||||||
|
*
|
||||||
|
* Assigning a rider is not a status change — it is an INSERT. `createdeliveries`
|
||||||
|
* writes a row in `deliveries`, copies it into `deliveryqueues` for the rider's
|
||||||
|
* app, and moves `orders.orderstatus`, all in one transaction. Until it runs,
|
||||||
|
* a delivery order is not on the deliveries page at all, because deliveries are
|
||||||
|
* their own table and not a filter over orders.
|
||||||
|
*
|
||||||
|
* That makes the payload load-bearing in a way a status write never is: every
|
||||||
|
* read that comes afterwards joins on the ids written here, and a join that
|
||||||
|
* misses drops the row silently. Everything in this file exists to stop a
|
||||||
|
* delivery being created that nobody can then see.
|
||||||
|
*
|
||||||
|
* Pure, so the rules below can be tested without a rider, an order or a
|
||||||
|
* network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has this order been turned into a delivery yet?
|
||||||
|
*
|
||||||
|
* Two signals, because one of them is younger than the data.
|
||||||
|
*
|
||||||
|
* - `deliveryid` on the order row. The direct answer, and it was unavailable
|
||||||
|
* until this work: `orders.deliveryid` was never written, and the orders
|
||||||
|
* query aliased the column away so the field came back 0 on every row ever
|
||||||
|
* returned. Both are fixed, but only for deliveries created from now on.
|
||||||
|
* - `assigned`, the set of `orderheaderid`s the deliveries list already holds.
|
||||||
|
* This is what covers the rows that predate the fix — production's existing
|
||||||
|
* deliveries all sit against an order whose `deliveryid` is still null.
|
||||||
|
*
|
||||||
|
* `rider` reads like a third signal and is not: it comes from a join on the
|
||||||
|
* rider's `app_users` row and is empty on plenty of orders that DO have a
|
||||||
|
* delivery, so testing it would offer to assign jobs already out with somebody.
|
||||||
|
*/
|
||||||
|
export function isUnassigned(row: OrderRow, assigned?: ReadonlySet<number>): boolean {
|
||||||
|
if (row.deliveryid) return false;
|
||||||
|
return !assigned?.has(row.orderheaderid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The orders the deliveries list says are spoken for.
|
||||||
|
*
|
||||||
|
* Built from whatever `getdeliveries` returned for the same window the orders
|
||||||
|
* came from, which is the pairing the Sales page already has in hand.
|
||||||
|
*/
|
||||||
|
export function assignedFrom(deliveries: readonly { orderheaderid?: number }[]): Set<number> {
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const row of deliveries) if (row.orderheaderid) out.add(row.orderheaderid);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this order need a rider at all?
|
||||||
|
*
|
||||||
|
* NOT `deliverytype`. That field is empty on every order in production — all
|
||||||
|
* 379 of Suriya Store's — so filtering on it hides the entire list. The drop
|
||||||
|
* address is the honest test: an order with somewhere to be delivered to is a
|
||||||
|
* delivery, and a counter sale has neither address nor customer.
|
||||||
|
*/
|
||||||
|
export function needsDelivery(row: OrderRow): boolean {
|
||||||
|
return Boolean((row.deliveryaddress ?? '').trim() || (row.deliverycustomer ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders waiting for a rider, oldest first.
|
||||||
|
*
|
||||||
|
* Oldest first, against the newest-first convention everywhere else on the
|
||||||
|
* page, because this is a work queue rather than a log: the order that has
|
||||||
|
* waited longest is the one to deal with next.
|
||||||
|
*/
|
||||||
|
export function awaitingRider(
|
||||||
|
rows: readonly OrderRow[],
|
||||||
|
assigned?: ReadonlySet<number>,
|
||||||
|
): OrderRow[] {
|
||||||
|
return rows
|
||||||
|
.filter((row) => isUnassigned(row, assigned) && needsDelivery(row) && !isFinished(row))
|
||||||
|
.sort((a, b) => placedAt(a) - placedAt(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An order that is already done or called off wants no rider. */
|
||||||
|
function isFinished(row: OrderRow): boolean {
|
||||||
|
const status = (row.orderstatus ?? '').trim().toLowerCase();
|
||||||
|
return status.includes('deliver') || status.includes('cancel') || status.includes('reject');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the order was placed, as an instant. 0 when the stamp is unreadable. */
|
||||||
|
export function placedAt(row: OrderRow): number {
|
||||||
|
const at = new Date(row.orderdate ?? '').getTime();
|
||||||
|
return Number.isNaN(at) ? 0 : at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long an order has been waiting for a rider. `null` when undatable. */
|
||||||
|
export function waitingMs(row: OrderRow, now = Date.now()): number | null {
|
||||||
|
const at = placedAt(row);
|
||||||
|
if (at === 0) return null;
|
||||||
|
return Math.max(0, now - at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Whether it can be assigned at all ───────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface Assignability {
|
||||||
|
canAssign: boolean;
|
||||||
|
/** Why not, in words for an operator. Empty when it can. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can a delivery for this order actually be created AND then seen?
|
||||||
|
*
|
||||||
|
* The second half is the part worth guarding. `createdeliveries` will happily
|
||||||
|
* insert a row with a zero `applocationid` and report success — but three of
|
||||||
|
* the reads that follow join `app_location` and `app_locationconfig` on it, so
|
||||||
|
* the job would exist, occupy the rider's queue, and never appear on any
|
||||||
|
* screen. Verified: Suriya Store NSN (location 1172) carries
|
||||||
|
* `applocationid: 0` today, so this is not hypothetical.
|
||||||
|
*
|
||||||
|
* Refusing up front is the honest answer. The alternative is a button that
|
||||||
|
* reports success and loses the order, which is the worst outcome available
|
||||||
|
* and exactly what "it worked on my test tenant" produces.
|
||||||
|
*/
|
||||||
|
export function assignability(
|
||||||
|
row: OrderRow,
|
||||||
|
branch: TenantLocation | undefined,
|
||||||
|
assigned?: ReadonlySet<number>,
|
||||||
|
): Assignability {
|
||||||
|
const applocationid = row.applocationid || branch?.applocationid || 0;
|
||||||
|
|
||||||
|
if (!row.orderheaderid) {
|
||||||
|
return { canAssign: false, reason: 'This order has no id to attach a delivery to.' };
|
||||||
|
}
|
||||||
|
if (!applocationid) {
|
||||||
|
return {
|
||||||
|
canAssign: false,
|
||||||
|
reason:
|
||||||
|
'This branch has no delivery region set, so a job created for it would not appear anywhere. Set the region on the branch first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!row.locationid) {
|
||||||
|
return { canAssign: false, reason: 'This order is not attached to a branch.' };
|
||||||
|
}
|
||||||
|
if (!needsDelivery(row)) {
|
||||||
|
return { canAssign: false, reason: 'This order has no delivery address.' };
|
||||||
|
}
|
||||||
|
if (!isUnassigned(row, assigned)) {
|
||||||
|
return { canAssign: false, reason: 'A rider is already on this one.' };
|
||||||
|
}
|
||||||
|
return { canAssign: true, reason: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The payload ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status a new delivery is created with.
|
||||||
|
*
|
||||||
|
* `pending`, lowercase, and it matters more than it looks. The rider's app
|
||||||
|
* reads its jobs from `getdeliveryqueues`, which filters
|
||||||
|
* `WHERE a.orderstatus = 'pending' AND a.userid = ?`. Any other word — and
|
||||||
|
* "Assigned" is the obvious one to reach for, because the order side uses
|
||||||
|
* capitalised words — creates a job the assigned rider can never see.
|
||||||
|
*
|
||||||
|
* The delivery ladder is its own vocabulary and all of it is lowercase:
|
||||||
|
* pending → accepted → arrived → picked → active → delivered, with cancelled
|
||||||
|
* and skipped off to the side.
|
||||||
|
*/
|
||||||
|
export const DELIVERY_CREATED_STATUS = 'pending';
|
||||||
|
|
||||||
|
/** Fiesta wants `YYYY-MM-DD HH:MM:SS` in local time, not an ISO instant. */
|
||||||
|
export function stampNow(now = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
|
||||||
|
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the date half, for `deliverydate`. */
|
||||||
|
export function dateOf(now = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the `createdeliveries` array.
|
||||||
|
*
|
||||||
|
* `Pickupaddress` is capitalised. That is not a typo here — it is the JSON tag
|
||||||
|
* on `models.Deliveries`, alone among fifty lowercase ones, and sending
|
||||||
|
* `pickupaddress` instead means the address is simply dropped.
|
||||||
|
*/
|
||||||
|
export interface DeliveryDraft {
|
||||||
|
orderheaderid: number;
|
||||||
|
orderid: string;
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
applocationid: number;
|
||||||
|
partnerid: number;
|
||||||
|
configid: number;
|
||||||
|
moduleid: number;
|
||||||
|
categoryid: number;
|
||||||
|
subcategoryid: number;
|
||||||
|
userid: number;
|
||||||
|
orderstatus: string;
|
||||||
|
deliverydate: string;
|
||||||
|
assigntime: string;
|
||||||
|
itemcount: number;
|
||||||
|
orderamount: number;
|
||||||
|
deliveryamt: number;
|
||||||
|
deliverycharges: number;
|
||||||
|
paymenttype: number;
|
||||||
|
customerid: number;
|
||||||
|
deliverycustomerid: number;
|
||||||
|
pickupcustomer: string;
|
||||||
|
pickupcontactno: string;
|
||||||
|
pickuplocationid: number;
|
||||||
|
Pickupaddress: string;
|
||||||
|
pickuplat: string;
|
||||||
|
pickuplon: string;
|
||||||
|
deliverycustomer: string;
|
||||||
|
deliverycontactno: string;
|
||||||
|
deliverylocationid: number;
|
||||||
|
deliveryaddress: string;
|
||||||
|
deliverylat: string;
|
||||||
|
deliverylong: string;
|
||||||
|
ordernotes: string;
|
||||||
|
kms: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the delivery row for one order and one rider.
|
||||||
|
*
|
||||||
|
* Every id is carried across from the order rather than defaulted, because the
|
||||||
|
* delivery inherits the order's place in the world — the same tenant, branch,
|
||||||
|
* region, partner and category. The old console's rows show what happens when
|
||||||
|
* they are not: production deliveries sit there with `itemcount: 0` and
|
||||||
|
* `orderamount: 0` against orders worth ₹80, so the deliveries page cannot
|
||||||
|
* show a rider what they are carrying. Those are copied here.
|
||||||
|
*
|
||||||
|
* `branch` supplies the pickup end and the region when the order row is thin
|
||||||
|
* about them; the order always wins where it has a value.
|
||||||
|
*/
|
||||||
|
export function buildDelivery(
|
||||||
|
row: OrderRow,
|
||||||
|
rider: RiderInfo,
|
||||||
|
branch: TenantLocation | undefined,
|
||||||
|
now = new Date(),
|
||||||
|
): DeliveryDraft {
|
||||||
|
const at = stampNow(now);
|
||||||
|
return {
|
||||||
|
orderheaderid: row.orderheaderid,
|
||||||
|
orderid: row.orderid ?? '',
|
||||||
|
tenantid: row.tenantid ?? branch?.tenantid ?? 0,
|
||||||
|
locationid: row.locationid ?? branch?.locationid ?? 0,
|
||||||
|
applocationid: row.applocationid || branch?.applocationid || 0,
|
||||||
|
partnerid: row.partnerid ?? 0,
|
||||||
|
configid: row.configid ?? 0,
|
||||||
|
moduleid: row.moduleid ?? 0,
|
||||||
|
categoryid: row.categoryid ?? 0,
|
||||||
|
subcategoryid: row.subcategoryid ?? 0,
|
||||||
|
|
||||||
|
userid: rider.userid,
|
||||||
|
orderstatus: DELIVERY_CREATED_STATUS,
|
||||||
|
// The date the job is for, and the moment it was handed out. Both are
|
||||||
|
// written by us: the backend stamps neither on create.
|
||||||
|
deliverydate: dateOf(now),
|
||||||
|
assigntime: at,
|
||||||
|
|
||||||
|
// What the rider is carrying, so the deliveries page can say so.
|
||||||
|
itemcount: row.itemcount ?? row.quantity ?? 0,
|
||||||
|
orderamount: row.orderamount ?? row.ordervalue ?? 0,
|
||||||
|
deliveryamt: row.ordervalue ?? row.orderamount ?? 0,
|
||||||
|
deliverycharges: row.deliverycharge ?? 0,
|
||||||
|
paymenttype: row.paymenttype ?? 0,
|
||||||
|
|
||||||
|
customerid: row.customerid ?? 0,
|
||||||
|
deliverycustomerid: row.deliverycustomerid ?? 0,
|
||||||
|
|
||||||
|
// Pickup: the shop. The order carries it, and the branch is the fallback
|
||||||
|
// for the orders that do not.
|
||||||
|
pickupcustomer: row.pickupcustomer || branch?.locationname || '',
|
||||||
|
pickupcontactno: row.pickupcontactno || branch?.contactno || '',
|
||||||
|
pickuplocationid: row.pickuplocationid || row.locationid || 0,
|
||||||
|
Pickupaddress: row.pickupaddress || branch?.address || '',
|
||||||
|
pickuplat: row.pickuplat || branch?.latitude || '',
|
||||||
|
pickuplon: row.pickuplong || branch?.longitude || '',
|
||||||
|
|
||||||
|
// Drop: the shopper. No fallback — an order without these was refused by
|
||||||
|
// `assignability` before it reached here.
|
||||||
|
deliverycustomer: row.deliverycustomer ?? '',
|
||||||
|
deliverycontactno: row.deliverycontactno ?? '',
|
||||||
|
deliverylocationid: row.deliverylocationid ?? 0,
|
||||||
|
deliveryaddress: row.deliveryaddress ?? '',
|
||||||
|
deliverylat: row.deliverylat ?? '',
|
||||||
|
deliverylong: row.deliverylong ?? '',
|
||||||
|
|
||||||
|
ordernotes: row.ordernotes ?? '',
|
||||||
|
kms: row.kms ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole batch, for orders that can take one.
|
||||||
|
*
|
||||||
|
* `createdeliveries` accepts an array and writes each row in the same
|
||||||
|
* transaction, so assigning eight orders to one rider is one call. Orders that
|
||||||
|
* fail `assignability` are dropped rather than sent — the endpoint reports one
|
||||||
|
* outcome for the whole array, so a row that cannot work takes the good ones
|
||||||
|
* down with it.
|
||||||
|
*/
|
||||||
|
export function buildDeliveries(
|
||||||
|
rows: readonly OrderRow[],
|
||||||
|
rider: RiderInfo,
|
||||||
|
branchOf: (row: OrderRow) => TenantLocation | undefined,
|
||||||
|
now = new Date(),
|
||||||
|
assigned?: ReadonlySet<number>,
|
||||||
|
): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } {
|
||||||
|
const drafts: DeliveryDraft[] = [];
|
||||||
|
const skipped: { row: OrderRow; reason: string }[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const branch = branchOf(row);
|
||||||
|
const verdict = assignability(row, branch, assigned);
|
||||||
|
if (verdict.canAssign) {
|
||||||
|
drafts.push(buildDelivery(row, rider, branch, now));
|
||||||
|
} else {
|
||||||
|
skipped.push({ row, reason: verdict.reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { drafts, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a rider reads in the picker. Falls back through the name fields. */
|
||||||
|
export function riderName(rider: RiderInfo): string {
|
||||||
|
const full = (rider.fullname ?? '').trim();
|
||||||
|
if (full) return full;
|
||||||
|
const joined = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim();
|
||||||
|
return joined || `Rider ${rider.userid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vehicle line under the name. Empty when nothing is recorded. */
|
||||||
|
export function riderVehicle(rider: RiderInfo): string {
|
||||||
|
return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
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, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
import { Banknote, Bike, Calculator, Clock, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX, X } from 'lucide-react';
|
||||||
import type { DateRange } from '@/api/insights';
|
import type { DateRange } from '@/api/insights';
|
||||||
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
|
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
@@ -34,8 +35,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 { assignability, assignedFrom, awaitingRider, waitingMs } from '../assignDelivery';
|
||||||
|
import { useSelection } from '@/components/useSelection';
|
||||||
|
import './deliveries.css';
|
||||||
|
|
||||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sales — app orders, delivery jobs and counter bills.
|
* Sales — app orders, delivery jobs and counter bills.
|
||||||
@@ -160,6 +165,70 @@ export function SalesPage() {
|
|||||||
return { value, charges, unassigned };
|
return { value, charges, unassigned };
|
||||||
}, [deliveryRows]);
|
}, [deliveryRows]);
|
||||||
|
|
||||||
|
/* ── The assignment queue ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders with nobody riding them yet.
|
||||||
|
*
|
||||||
|
* Drawn from the ORDERS read, not the deliveries one, and that is the whole
|
||||||
|
* point: an order with no rider has no delivery row, so it appears on the
|
||||||
|
* deliveries page under no filter at all. Before this queue existed there was
|
||||||
|
* nowhere in the console such an order could be seen.
|
||||||
|
*
|
||||||
|
* Not filtered by the status strip. A shop working through its backlog wants
|
||||||
|
* every order still waiting, and "Pending" is not even the status they arrive
|
||||||
|
* with — production orders sit at `created`.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Which orders the deliveries list already accounts for.
|
||||||
|
*
|
||||||
|
* The order row's own `deliveryid` is the direct answer, but it is only
|
||||||
|
* populated for deliveries created after this backend ships — nothing wrote
|
||||||
|
* `orders.deliveryid` before, and the orders query aliased the column away.
|
||||||
|
* Every delivery already in production sits against an order that still
|
||||||
|
* reads 0, so without this the queue would offer to assign them all again.
|
||||||
|
*/
|
||||||
|
const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
|
||||||
|
|
||||||
|
const queue = useMemo(() => awaitingRider(allOrders, assigned), [allOrders, assigned]);
|
||||||
|
|
||||||
|
const branchOf = useMemo(
|
||||||
|
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
||||||
|
[branches],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the rows that can actually be sent are selectable.
|
||||||
|
*
|
||||||
|
* Not `queue` — an order whose branch has no delivery region has its own tick
|
||||||
|
* box disabled, and handing every queue id to the selection meant "select
|
||||||
|
* all" ticked it anyway: the box went grey-and-checked and the bar counted
|
||||||
|
* six when only five could go. The drawer caught it and named it, which is
|
||||||
|
* the safety net working, but a count that disagrees with what happens is
|
||||||
|
* how a person stops trusting the ticks.
|
||||||
|
*/
|
||||||
|
const selectable = useMemo(
|
||||||
|
() =>
|
||||||
|
queue
|
||||||
|
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
||||||
|
.map((row) => row.orderheaderid),
|
||||||
|
[queue, branchOf, assigned],
|
||||||
|
);
|
||||||
|
|
||||||
|
const picked = useSelection(selectable);
|
||||||
|
const [isAssigning, setAssigning] = useState(false);
|
||||||
|
|
||||||
|
const pickedOrders = useMemo(
|
||||||
|
() => queue.filter((row) => picked.has(row.orderheaderid)),
|
||||||
|
[queue, picked],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The oldest wait in the queue — the number that says how bad it is. */
|
||||||
|
const longestWait = useMemo(() => {
|
||||||
|
const waits = queue.map((row) => waitingMs(row)).filter((ms): ms is number => ms !== null);
|
||||||
|
return waits.length > 0 ? Math.max(...waits) : null;
|
||||||
|
}, [queue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -194,11 +263,11 @@ export function SalesPage() {
|
|||||||
label="Online Orders"
|
label="Online Orders"
|
||||||
icon={<ShoppingCart size={14} />}
|
icon={<ShoppingCart size={14} />}
|
||||||
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
|
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
|
||||||
isActive={tab === 'orders' || tab === 'deliveries'}
|
isActive={tab !== 'counter'}
|
||||||
onClick={() => setTab(tab === 'deliveries' ? 'deliveries' : 'orders')}
|
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(tab === 'orders' || tab === 'deliveries') ? (
|
{tab !== 'counter' ? (
|
||||||
<>
|
<>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Orders"
|
label="Orders"
|
||||||
@@ -207,6 +276,18 @@ 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} />}
|
||||||
@@ -229,8 +310,9 @@ export function SalesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* The status strip. Only orders and deliveries have a lifecycle — a
|
{/* The status strip. Only orders and deliveries have a lifecycle — a
|
||||||
counter bill is rung and done, so it has nothing to filter by. */}
|
counter bill is rung and done, so it has nothing to filter by, and
|
||||||
{tab !== 'counter' ? (
|
the assignment queue is already the answer to one status question. */}
|
||||||
|
{tab === 'orders' || tab === 'deliveries' ? (
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<HStack gap={0.5} wrap="wrap">
|
||||||
{STATUS_TABS.map((entry) => (
|
{STATUS_TABS.map((entry) => (
|
||||||
<StatusTab
|
<StatusTab
|
||||||
@@ -304,6 +386,72 @@ 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' ? (
|
||||||
<>
|
<>
|
||||||
@@ -425,13 +573,225 @@ 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
|
||||||
|
used as captured, because the drawer can now CHANGE the row it is
|
||||||
|
showing. Marking a job delivered updated the list behind and left the
|
||||||
|
drawer reading "Pending", still offering the buttons — the state was
|
||||||
|
a snapshot taken when it opened. Looking it up keeps it honest, and
|
||||||
|
falls back to the captured row while a refetch is in flight. */}
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
|
<OrderDetailDrawer
|
||||||
|
row={freshRow(detail, allOrders, allDeliveries)}
|
||||||
|
kind={detail.kind}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open row, as the list currently has it.
|
||||||
|
*
|
||||||
|
* Keyed on `deliveryid` for a job and `orderheaderid` for an order — both are
|
||||||
|
* stable primary keys, unlike `orderid`, which is not unique in this tenant's
|
||||||
|
* data. Returns the captured row when the list has not caught up yet, so the
|
||||||
|
* drawer never blanks mid-refetch.
|
||||||
|
*/
|
||||||
|
function freshRow(
|
||||||
|
detail: { row: OrderRow | DeliveryRow; kind: RowKind },
|
||||||
|
orders: readonly OrderRow[],
|
||||||
|
deliveries: readonly DeliveryRow[],
|
||||||
|
): OrderRow | DeliveryRow {
|
||||||
|
if (detail.kind === 'delivery') {
|
||||||
|
const id = (detail.row as DeliveryRow).deliveryid;
|
||||||
|
return deliveries.find((row) => row.deliveryid === id) ?? detail.row;
|
||||||
|
}
|
||||||
|
const id = detail.row.orderheaderid;
|
||||||
|
return orders.find((row) => row.orderheaderid === id) ?? detail.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Past this, a shopper is waiting long enough that somebody should notice. */
|
||||||
|
const FIFTEEN_MINUTES = 15 * 60_000;
|
||||||
|
const THIRTY_MINUTES = 30 * 60_000;
|
||||||
|
|
||||||
|
/* ── The assignment queue ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders with nobody riding them.
|
||||||
|
*
|
||||||
|
* Its own table rather than a filter on `OrdersTable`, because it answers a
|
||||||
|
* different question and therefore wants different columns. Money and item
|
||||||
|
* counts matter when you are reading a day's trading; when you are deciding who
|
||||||
|
* takes what next, only three things do — where it is going, how long it has
|
||||||
|
* been sitting there, and whether it can be sent at all.
|
||||||
|
*/
|
||||||
|
function QueueTable({
|
||||||
|
rows,
|
||||||
|
isLoading,
|
||||||
|
showBranch,
|
||||||
|
selection,
|
||||||
|
branchOf,
|
||||||
|
assigned,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
rows: OrderRow[];
|
||||||
|
isLoading: boolean;
|
||||||
|
showBranch: boolean;
|
||||||
|
selection: ReturnType<typeof useSelection>;
|
||||||
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||||
|
assigned: ReadonlySet<number>;
|
||||||
|
onOpen: (row: OrderRow) => void;
|
||||||
|
}) {
|
||||||
|
if (isLoading) return <Loading what="orders" />;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
icon={<Bike size={22} />}
|
||||||
|
title="Every order has a rider"
|
||||||
|
body="Nothing in this date range is waiting to be assigned. New orders appear here as they come in."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding={0} elevation="low">
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Select every order in the queue"
|
||||||
|
checked={selection.allChosen}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = selection.someChosen;
|
||||||
|
}}
|
||||||
|
onChange={selection.toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<Th>Order</Th>
|
||||||
|
{showBranch ? <Th>Branch</Th> : null}
|
||||||
|
<Th>Going to</Th>
|
||||||
|
<Th>Waiting</Th>
|
||||||
|
<Th>Amount</Th>
|
||||||
|
<Th>Status</Th>
|
||||||
|
<Th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const verdict = assignability(row, branchOf(row), assigned);
|
||||||
|
const waited = waitingMs(row);
|
||||||
|
return (
|
||||||
|
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||||
|
{/* The tick stops the click here. Clicking a checkbox to
|
||||||
|
select and getting a drawer instead is the fastest way to
|
||||||
|
make an operator stop trusting the boxes. */}
|
||||||
|
<td
|
||||||
|
className="tick-cell"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
borderBottom:
|
||||||
|
'1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
paddingTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select order ${row.orderid || row.orderheaderid}`}
|
||||||
|
checked={selection.has(row.orderheaderid)}
|
||||||
|
/* An order that cannot be assigned cannot be ticked.
|
||||||
|
Letting it into a batch means the whole array is
|
||||||
|
refused, taking the good orders with it. */
|
||||||
|
disabled={!verdict.canAssign}
|
||||||
|
onChange={() => selection.toggle(row.orderheaderid)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<Td>
|
||||||
|
<Primary>
|
||||||
|
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
|
||||||
|
</Primary>
|
||||||
|
<Secondary>{stamp(row.orderdate)}</Secondary>
|
||||||
|
</Td>
|
||||||
|
{showBranch ? (
|
||||||
|
<Td>
|
||||||
|
<Primary>{branchLabel(row.locationname)}</Primary>
|
||||||
|
<Secondary>{row.applocation}</Secondary>
|
||||||
|
</Td>
|
||||||
|
) : null}
|
||||||
|
<Td>
|
||||||
|
<Primary w={200}>{row.deliverycustomer || '—'}</Primary>
|
||||||
|
<Secondary w={200}>{row.deliveryaddress || row.deliverysuburb}</Secondary>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{waited === null ? (
|
||||||
|
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||||
|
) : (
|
||||||
|
<span className="waited" data-tone={waitTone(waited)}>
|
||||||
|
<Clock size={11} />
|
||||||
|
{shortAge(waited)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td isStrong>
|
||||||
|
<Mono>{moneyExact(orderValue(row))}</Mono>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Chip
|
||||||
|
label={row.orderstatus}
|
||||||
|
color={statusColor(ORDER_STATUS, row.orderstatus)}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{/* The reason is on the row rather than only in the drawer.
|
||||||
|
A branch with no delivery region is a configuration
|
||||||
|
problem, and the person who can see it is the one
|
||||||
|
looking at this list. */}
|
||||||
|
{verdict.canAssign ? (
|
||||||
|
<ViewHint />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: '#8a5a00',
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: 190,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{verdict.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitTone(ms: number): 'neutral' | 'warning' | 'critical' {
|
||||||
|
if (ms >= THIRTY_MINUTES) return 'critical';
|
||||||
|
if (ms >= FIFTEEN_MINUTES) return 'warning';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Orders table ────────────────────────────────────────────────────────── */
|
/* ── Orders table ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function OrdersTable({
|
function OrdersTable({
|
||||||
|
|||||||
188
src/features/store-admin/pages/deliveries.css
Normal file
188
src/features/store-admin/pages/deliveries.css
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/* ══ Assigning riders ══════════════════════════════════════════════════════
|
||||||
|
Built on the site's tokens and on the Console's .panel/.pill vocabulary, so
|
||||||
|
the drawer and the queue read as the same product as everything around them.
|
||||||
|
Only what is particular to assignment is defined here. */
|
||||||
|
|
||||||
|
/* ── The orders in the drawer ───────────────────────────────────────────── */
|
||||||
|
/* A list, not a table: this is a confirmation of what is about to be handed
|
||||||
|
over, and the columns of the queue behind it would only be repeated here. */
|
||||||
|
.assign-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.assign-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.assign-row:last-child { border-bottom: none; }
|
||||||
|
.assign-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.assign-row-main strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.assign-row-main span {
|
||||||
|
font: 400 11.5px/1.35 var(--font-sans);
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
/* Addresses run long and the money column must not be pushed off screen. */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.assign-row-side { display: flex; flex-direction: column; gap: 2px; text-align: right; }
|
||||||
|
.assign-row-side strong {
|
||||||
|
font: 600 13px/1.3 var(--font-sans);
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.assign-row-side span { font: 400 11px/1.3 var(--font-sans); color: var(--color-ink-3); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Orders held back ───────────────────────────────────────────────────── */
|
||||||
|
/* Amber, not red. Nothing has failed — these were never sent, and the operator
|
||||||
|
still has them. Red would say something was lost. */
|
||||||
|
.assign-held {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid #f2d9a8;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fdf6e8;
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
.assign-held svg { flex: none; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Empty states ───────────────────────────────────────────────────────── */
|
||||||
|
.assign-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px dashed var(--color-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
}
|
||||||
|
.assign-empty svg { flex: none; }
|
||||||
|
|
||||||
|
/* ── The rider picker ───────────────────────────────────────────────────── */
|
||||||
|
/* Buttons rather than a <select>: there are rarely more than a handful of
|
||||||
|
riders on duty, and the vehicle and phone number are half of how an operator
|
||||||
|
picks between two names they only half recognise. A dropdown hides both. */
|
||||||
|
.rider-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.rider-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rider-option:hover { border-color: var(--color-line-strong, var(--color-ink-4)); }
|
||||||
|
.rider-option[data-chosen='true'] {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
.rider-option:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.rider-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
}
|
||||||
|
.rider-option[data-chosen='true'] .rider-mark {
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rider-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||||
|
.rider-text strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.rider-text span { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
.rider-contact {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font: 500 11.5px/1 var(--font-sans);
|
||||||
|
color: var(--color-ink-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The waiting badge on a queue row ───────────────────────────────────── */
|
||||||
|
/* How long an order has sat unassigned is the one fact that decides what an
|
||||||
|
operator does next, so it is not left as a timestamp to subtract. Amber past
|
||||||
|
fifteen minutes, red past thirty — a shopper who ordered half an hour ago and
|
||||||
|
has no rider is the reason to open this page. */
|
||||||
|
.waited {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font: 500 11.5px/1.5 var(--font-sans);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
.waited[data-tone='warning'] { background: #fdf6e8; color: #8a5a00; }
|
||||||
|
.waited[data-tone='critical'] { background: #fdefee; color: #b3261e; }
|
||||||
|
|
||||||
|
/* ── The queue's action bar ─────────────────────────────────────────────── */
|
||||||
|
/* Sticks to the bottom while the operator works down a long list, so the
|
||||||
|
button is never a scroll away from the ticks that armed it. */
|
||||||
|
.assign-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--color-brand);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
box-shadow: 0 -6px 20px -12px rgb(15 23 42 / 0.25);
|
||||||
|
}
|
||||||
|
.assign-bar-count {
|
||||||
|
font: 600 13px/1.3 var(--font-sans);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The tick column. Its own cell so clicking a box never opens the row. */
|
||||||
|
.tick-cell { width: 34px; padding-left: 12px; }
|
||||||
|
|
||||||
|
/* ── Recording progress by hand ─────────────────────────────────────────── */
|
||||||
|
/* Three plain buttons rather than a dropdown-and-save. Each is one decision
|
||||||
|
the operator has already made before opening the drawer; a two-step control
|
||||||
|
would add a confirmation to a correction that is itself reversible. */
|
||||||
|
.progress-moves { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.progress-move {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
font: 500 12.5px/1 var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.progress-move:hover:not(:disabled) { background: var(--color-surface-sunken); }
|
||||||
|
.progress-move:disabled { color: var(--color-ink-4); cursor: not-allowed; }
|
||||||
|
.progress-move[data-danger='true']:not(:disabled) { color: #b3261e; border-color: #f0cfcd; }
|
||||||
|
.progress-move:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { useQueries, useQuery, type UseQueryOptions } from '@tanstack/react-query';
|
import { useQueries, useQuery, type UseQueryOptions } from '@tanstack/react-query';
|
||||||
import { catalogueApi, type CatalogueQuery } from '@/api/catalogue';
|
import { catalogueApi, type CatalogueQuery } from '@/api/catalogue';
|
||||||
|
import { deliveriesApi } from '@/api/deliveries';
|
||||||
import { insightsApi, type DateRange, type OrderQuery } from '@/api/insights';
|
import { insightsApi, type DateRange, type OrderQuery } from '@/api/insights';
|
||||||
import { productsApi } from '@/api/products';
|
import { productsApi } from '@/api/products';
|
||||||
import { posUsersApi, staffApi } from '@/api/people';
|
import { posUsersApi, staffApi } from '@/api/people';
|
||||||
@@ -341,6 +342,26 @@ export function useDeliveries(query: OrderQuery | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riders on duty in one delivery region.
|
||||||
|
*
|
||||||
|
* `live` rather than `stable`, unlike the other reference lists: this one is
|
||||||
|
* not reference data. A rider clocking on or off changes it during the shift,
|
||||||
|
* and a picker offering somebody who went home twenty minutes ago sends the
|
||||||
|
* order nowhere.
|
||||||
|
*
|
||||||
|
* Scoped by `applocationid`. Passing a tenant instead returns an empty list
|
||||||
|
* with a 200 — see `deliveriesApi.riders`.
|
||||||
|
*/
|
||||||
|
export function useRiders(applocationid: number | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.insights.riders(applocationid ?? 0),
|
||||||
|
queryFn: () => deliveriesApi.riders({ applocationid: applocationid as number }),
|
||||||
|
enabled: Boolean(applocationid),
|
||||||
|
...live,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── People ──────────────────────────────────────────────────────────────── */
|
/* ── People ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The back-office directory. Role names arrive resolved; never map ids here. */
|
/** The back-office directory. Role names arrive resolved; never map ids here. */
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const queryKeys = {
|
|||||||
[...queryKeys.insights.all, 'order-list', params] as const,
|
[...queryKeys.insights.all, 'order-list', params] as const,
|
||||||
deliveryList: (params: Record<string, unknown>) =>
|
deliveryList: (params: Record<string, unknown>) =>
|
||||||
[...queryKeys.insights.all, 'delivery-list', params] as const,
|
[...queryKeys.insights.all, 'delivery-list', params] as const,
|
||||||
|
/** Keyed on the delivery region, which is what the endpoint filters on. */
|
||||||
|
riders: (applocationid: number) =>
|
||||||
|
[...queryKeys.insights.all, 'riders', applocationid] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
people: {
|
people: {
|
||||||
|
|||||||
Reference in New Issue
Block a user