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;
|
||||
pickupaddress?: string;
|
||||
pickupsuburb?: string;
|
||||
/** The delivery half. Blank on an order nobody has been assigned to. */
|
||||
/**
|
||||
* The delivery half. Blank on an order nobody has been assigned to.
|
||||
*
|
||||
* `deliveryid` is the one to read for "has a rider been assigned yet" — it is
|
||||
* 0 until `createdeliveries` runs, and it is 0 on every unassigned row in
|
||||
* production. `rider` looks like the same test and is not: it is filled from
|
||||
* a join and is empty on rows that DO have a delivery.
|
||||
*/
|
||||
deliveryid?: number;
|
||||
rider?: string;
|
||||
ridercontactno?: string;
|
||||
@@ -450,6 +457,77 @@ export interface OrderRow {
|
||||
pickuptime?: string;
|
||||
deliverytime?: string;
|
||||
canceltime?: string;
|
||||
|
||||
/*
|
||||
* The ids a delivery row has to be built from.
|
||||
*
|
||||
* All of these are on the wire and none were typed, because nothing read
|
||||
* them until assignment existed. `createdeliveries` copies them onto the
|
||||
* `deliveries` row, and the reads that follow join on them — so an order
|
||||
* that arrives without one produces a delivery that cannot be seen. See
|
||||
* `assignDelivery.ts` for which of them are load-bearing.
|
||||
*/
|
||||
applocationid?: number;
|
||||
partnerid?: number;
|
||||
configid?: number;
|
||||
moduleid?: number;
|
||||
categoryid?: number;
|
||||
subcategoryid?: number;
|
||||
/** Who placed the order. Distinct from `deliverycustomerid`, which is 0. */
|
||||
customerid?: number;
|
||||
deliverycustomerid?: number;
|
||||
deliverylocationid?: number;
|
||||
pickuplocationid?: number;
|
||||
pickuplat?: string;
|
||||
pickuplong?: string;
|
||||
deliverylat?: string;
|
||||
deliverylong?: string;
|
||||
droplat?: string;
|
||||
droplon?: string;
|
||||
kms?: string;
|
||||
/**
|
||||
* Empty on every production order, so it CANNOT be used to tell a delivery
|
||||
* order from a collection. Typed only so nobody reaches for it and quietly
|
||||
* filters the whole list away — the drop address is the real test.
|
||||
*/
|
||||
deliverytype?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One rider, from `GET /partners/getriders` (`models.RiderInfo`).
|
||||
*
|
||||
* Only riders who are ON DUTY TODAY appear. The query requires a `riderlogs`
|
||||
* row stamped today with `logstatus = 0` and `app_userpools.onduty = 1`, so an
|
||||
* empty list means "nobody has clocked on", not "this shop has no riders" —
|
||||
* a distinction the picker has to make, or an operator spends the morning
|
||||
* wondering why the fleet vanished.
|
||||
*
|
||||
* `password` is deliberately absent for the same reason it is on `Staff`: the
|
||||
* endpoint returns it in clear, and not typing it is what stops a cell
|
||||
* rendering one.
|
||||
*/
|
||||
export interface RiderInfo {
|
||||
userid: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
fullname?: string;
|
||||
contactno?: string;
|
||||
partnerid?: number;
|
||||
applocationid?: number;
|
||||
applocation?: string;
|
||||
vehiclename?: string;
|
||||
vehicleno?: string;
|
||||
licenseno?: string;
|
||||
shiftid?: number;
|
||||
/** Shift window, as `HH:MM:SS`. */
|
||||
starttime?: string;
|
||||
endtime?: string;
|
||||
/** Today's log. `logstatus` 0 is on duty. */
|
||||
logdate?: string;
|
||||
login?: string;
|
||||
logout?: string;
|
||||
logstatus?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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 { Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow } from '@/api/types';
|
||||
import { DeliveryProgress } from './DeliveryProgress';
|
||||
import { branchLabel, moneyExact } from './format';
|
||||
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
||||
|
||||
@@ -268,6 +269,11 @@ export function OrderDetailDrawer({
|
||||
No rider assigned yet.
|
||||
</Text>
|
||||
)}
|
||||
{/* Recording progress by hand. Under the rider rather than beside
|
||||
the status chip at the top: it is a correction, not the normal
|
||||
way a job moves, and putting it in the header would invite it
|
||||
to be used as one. */}
|
||||
<DeliveryProgress job={job} />
|
||||
</VStack>
|
||||
) : order?.rider ? (
|
||||
<VStack gap={1}>
|
||||
|
||||
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 { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
||||
import { Banknote, Bike, Calculator, Clock, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX, X } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
|
||||
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
@@ -34,8 +35,12 @@ import {
|
||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||
import { shortAge } from '../posStatus';
|
||||
import { AssignRiderDrawer } from '../AssignRiderDrawer';
|
||||
import { assignability, assignedFrom, awaitingRider, waitingMs } from '../assignDelivery';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import './deliveries.css';
|
||||
|
||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
type Tab = 'orders' | 'toassign' | 'deliveries' | 'counter';
|
||||
|
||||
/**
|
||||
* Sales — app orders, delivery jobs and counter bills.
|
||||
@@ -160,6 +165,70 @@ export function SalesPage() {
|
||||
return { value, charges, unassigned };
|
||||
}, [deliveryRows]);
|
||||
|
||||
/* ── The assignment queue ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Orders with nobody riding them yet.
|
||||
*
|
||||
* Drawn from the ORDERS read, not the deliveries one, and that is the whole
|
||||
* point: an order with no rider has no delivery row, so it appears on the
|
||||
* deliveries page under no filter at all. Before this queue existed there was
|
||||
* nowhere in the console such an order could be seen.
|
||||
*
|
||||
* Not filtered by the status strip. A shop working through its backlog wants
|
||||
* every order still waiting, and "Pending" is not even the status they arrive
|
||||
* with — production orders sit at `created`.
|
||||
*/
|
||||
/**
|
||||
* Which orders the deliveries list already accounts for.
|
||||
*
|
||||
* The order row's own `deliveryid` is the direct answer, but it is only
|
||||
* populated for deliveries created after this backend ships — nothing wrote
|
||||
* `orders.deliveryid` before, and the orders query aliased the column away.
|
||||
* Every delivery already in production sits against an order that still
|
||||
* reads 0, so without this the queue would offer to assign them all again.
|
||||
*/
|
||||
const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
|
||||
|
||||
const queue = useMemo(() => awaitingRider(allOrders, assigned), [allOrders, assigned]);
|
||||
|
||||
const branchOf = useMemo(
|
||||
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
||||
[branches],
|
||||
);
|
||||
|
||||
/**
|
||||
* Only the rows that can actually be sent are selectable.
|
||||
*
|
||||
* Not `queue` — an order whose branch has no delivery region has its own tick
|
||||
* box disabled, and handing every queue id to the selection meant "select
|
||||
* all" ticked it anyway: the box went grey-and-checked and the bar counted
|
||||
* six when only five could go. The drawer caught it and named it, which is
|
||||
* the safety net working, but a count that disagrees with what happens is
|
||||
* how a person stops trusting the ticks.
|
||||
*/
|
||||
const selectable = useMemo(
|
||||
() =>
|
||||
queue
|
||||
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
||||
.map((row) => row.orderheaderid),
|
||||
[queue, branchOf, assigned],
|
||||
);
|
||||
|
||||
const picked = useSelection(selectable);
|
||||
const [isAssigning, setAssigning] = useState(false);
|
||||
|
||||
const pickedOrders = useMemo(
|
||||
() => queue.filter((row) => picked.has(row.orderheaderid)),
|
||||
[queue, picked],
|
||||
);
|
||||
|
||||
/** The oldest wait in the queue — the number that says how bad it is. */
|
||||
const longestWait = useMemo(() => {
|
||||
const waits = queue.map((row) => waitingMs(row)).filter((ms): ms is number => ms !== null);
|
||||
return waits.length > 0 ? Math.max(...waits) : null;
|
||||
}, [queue]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
@@ -194,11 +263,11 @@ export function SalesPage() {
|
||||
label="Online Orders"
|
||||
icon={<ShoppingCart size={14} />}
|
||||
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
|
||||
isActive={tab === 'orders' || tab === 'deliveries'}
|
||||
onClick={() => setTab(tab === 'deliveries' ? 'deliveries' : 'orders')}
|
||||
isActive={tab !== 'counter'}
|
||||
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
|
||||
/>
|
||||
|
||||
{(tab === 'orders' || tab === 'deliveries') ? (
|
||||
{tab !== 'counter' ? (
|
||||
<>
|
||||
<TabButton
|
||||
label="Orders"
|
||||
@@ -207,6 +276,18 @@ export function SalesPage() {
|
||||
isActive={tab === 'orders'}
|
||||
onClick={() => setTab('orders')}
|
||||
/>
|
||||
{/* Between Orders and Deliveries because that is where the work
|
||||
sits: an order becomes a delivery by passing through here.
|
||||
The badge is the count of shoppers currently waiting on
|
||||
somebody in this room, which is why it is the one tab whose
|
||||
number is worth acting on. */}
|
||||
<TabButton
|
||||
label="To assign"
|
||||
icon={<UserX size={14} />}
|
||||
badge={queue.length || undefined}
|
||||
isActive={tab === 'toassign'}
|
||||
onClick={() => setTab('toassign')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Deliveries"
|
||||
icon={<Truck size={14} />}
|
||||
@@ -229,8 +310,9 @@ export function SalesPage() {
|
||||
/>
|
||||
|
||||
{/* The status strip. Only orders and deliveries have a lifecycle — a
|
||||
counter bill is rung and done, so it has nothing to filter by. */}
|
||||
{tab !== 'counter' ? (
|
||||
counter bill is rung and done, so it has nothing to filter by, and
|
||||
the assignment queue is already the answer to one status question. */}
|
||||
{tab === 'orders' || tab === 'deliveries' ? (
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
{STATUS_TABS.map((entry) => (
|
||||
<StatusTab
|
||||
@@ -304,6 +386,72 @@ export function SalesPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── To assign ───────────────────────────────────────────────────── */}
|
||||
{tab === 'toassign' ? (
|
||||
<>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Waiting for a rider"
|
||||
value={count(queue.length)}
|
||||
note="nobody is carrying these yet"
|
||||
tone={queue.length > 0 ? 'warning' : 'success'}
|
||||
icon={<UserX size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Longest wait"
|
||||
value={longestWait === null ? '—' : shortAge(longestWait)}
|
||||
note="since the order was placed"
|
||||
tone={longestWait !== null && longestWait > THIRTY_MINUTES ? 'error' : 'neutral'}
|
||||
icon={<Clock size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Value held up"
|
||||
value={money(queue.reduce((sum, row) => sum + orderValue(row), 0))}
|
||||
note="across the queue"
|
||||
tone="neutral"
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Waiting for a rider"
|
||||
action={
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Oldest first
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<QueueTable
|
||||
rows={queue}
|
||||
isLoading={orders.isLoading}
|
||||
showBranch={selected === null}
|
||||
selection={picked}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
||||
/>
|
||||
{picked.count > 0 ? (
|
||||
<div className="assign-bar">
|
||||
<span className="assign-bar-count">
|
||||
{picked.count} order{picked.count === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
<HStack gap={1} align="center">
|
||||
<BarAction label="Clear" icon={<X size={14} />} onClick={picked.clear} />
|
||||
<Button
|
||||
label="Assign a rider"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Bike size={14} />}
|
||||
onClick={() => setAssigning(true)}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── Deliveries ──────────────────────────────────────────────────── */}
|
||||
{tab === 'deliveries' ? (
|
||||
<>
|
||||
@@ -425,13 +573,225 @@ export function SalesPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isAssigning && pickedOrders.length > 0 ? (
|
||||
<AssignRiderDrawer
|
||||
orders={pickedOrders}
|
||||
branchOf={branchOf}
|
||||
assigned={assigned}
|
||||
onClose={() => setAssigning(false)}
|
||||
onDone={picked.clear}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* The row is re-read from the live list on every render rather than
|
||||
used as captured, because the drawer can now CHANGE the row it is
|
||||
showing. Marking a job delivered updated the list behind and left the
|
||||
drawer reading "Pending", still offering the buttons — the state was
|
||||
a snapshot taken when it opened. Looking it up keeps it honest, and
|
||||
falls back to the captured row while a refetch is in flight. */}
|
||||
{detail ? (
|
||||
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
|
||||
<OrderDetailDrawer
|
||||
row={freshRow(detail, allOrders, allDeliveries)}
|
||||
kind={detail.kind}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open row, as the list currently has it.
|
||||
*
|
||||
* Keyed on `deliveryid` for a job and `orderheaderid` for an order — both are
|
||||
* stable primary keys, unlike `orderid`, which is not unique in this tenant's
|
||||
* data. Returns the captured row when the list has not caught up yet, so the
|
||||
* drawer never blanks mid-refetch.
|
||||
*/
|
||||
function freshRow(
|
||||
detail: { row: OrderRow | DeliveryRow; kind: RowKind },
|
||||
orders: readonly OrderRow[],
|
||||
deliveries: readonly DeliveryRow[],
|
||||
): OrderRow | DeliveryRow {
|
||||
if (detail.kind === 'delivery') {
|
||||
const id = (detail.row as DeliveryRow).deliveryid;
|
||||
return deliveries.find((row) => row.deliveryid === id) ?? detail.row;
|
||||
}
|
||||
const id = detail.row.orderheaderid;
|
||||
return orders.find((row) => row.orderheaderid === id) ?? detail.row;
|
||||
}
|
||||
|
||||
/** Past this, a shopper is waiting long enough that somebody should notice. */
|
||||
const FIFTEEN_MINUTES = 15 * 60_000;
|
||||
const THIRTY_MINUTES = 30 * 60_000;
|
||||
|
||||
/* ── The assignment queue ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Orders with nobody riding them.
|
||||
*
|
||||
* Its own table rather than a filter on `OrdersTable`, because it answers a
|
||||
* different question and therefore wants different columns. Money and item
|
||||
* counts matter when you are reading a day's trading; when you are deciding who
|
||||
* takes what next, only three things do — where it is going, how long it has
|
||||
* been sitting there, and whether it can be sent at all.
|
||||
*/
|
||||
function QueueTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
selection,
|
||||
branchOf,
|
||||
assigned,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: OrderRow[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
selection: ReturnType<typeof useSelection>;
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
onOpen: (row: OrderRow) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="orders" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Bike size={22} />}
|
||||
title="Every order has a rider"
|
||||
body="Nothing in this date range is waiting to be assigned. New orders appear here as they come in."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select every order in the queue"
|
||||
checked={selection.allChosen}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.someChosen;
|
||||
}}
|
||||
onChange={selection.toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Going to</Th>
|
||||
<Th>Waiting</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const verdict = assignability(row, branchOf(row), assigned);
|
||||
const waited = waitingMs(row);
|
||||
return (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||
{/* The tick stops the click here. Clicking a checkbox to
|
||||
select and getting a drawer instead is the fastest way to
|
||||
make an operator stop trusting the boxes. */}
|
||||
<td
|
||||
className="tick-cell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
borderBottom:
|
||||
'1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
verticalAlign: 'top',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select order ${row.orderid || row.orderheaderid}`}
|
||||
checked={selection.has(row.orderheaderid)}
|
||||
/* An order that cannot be assigned cannot be ticked.
|
||||
Letting it into a batch means the whole array is
|
||||
refused, taking the good orders with it. */
|
||||
disabled={!verdict.canAssign}
|
||||
onChange={() => selection.toggle(row.orderheaderid)}
|
||||
/>
|
||||
</td>
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.orderdate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td>
|
||||
<Primary>{branchLabel(row.locationname)}</Primary>
|
||||
<Secondary>{row.applocation}</Secondary>
|
||||
</Td>
|
||||
) : null}
|
||||
<Td>
|
||||
<Primary w={200}>{row.deliverycustomer || '—'}</Primary>
|
||||
<Secondary w={200}>{row.deliveryaddress || row.deliverysuburb}</Secondary>
|
||||
</Td>
|
||||
<Td>
|
||||
{waited === null ? (
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||
) : (
|
||||
<span className="waited" data-tone={waitTone(waited)}>
|
||||
<Clock size={11} />
|
||||
{shortAge(waited)}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td isStrong>
|
||||
<Mono>{moneyExact(orderValue(row))}</Mono>
|
||||
</Td>
|
||||
<Td>
|
||||
<Chip
|
||||
label={row.orderstatus}
|
||||
color={statusColor(ORDER_STATUS, row.orderstatus)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
{/* The reason is on the row rather than only in the drawer.
|
||||
A branch with no delivery region is a configuration
|
||||
problem, and the person who can see it is the one
|
||||
looking at this list. */}
|
||||
{verdict.canAssign ? (
|
||||
<ViewHint />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: '#8a5a00',
|
||||
display: 'block',
|
||||
maxWidth: 190,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{verdict.reason}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function waitTone(ms: number): 'neutral' | 'warning' | 'critical' {
|
||||
if (ms >= THIRTY_MINUTES) return 'critical';
|
||||
if (ms >= FIFTEEN_MINUTES) return 'warning';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/* ── Orders table ────────────────────────────────────────────────────────── */
|
||||
|
||||
function OrdersTable({
|
||||
|
||||
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 { catalogueApi, type CatalogueQuery } from '@/api/catalogue';
|
||||
import { deliveriesApi } from '@/api/deliveries';
|
||||
import { insightsApi, type DateRange, type OrderQuery } from '@/api/insights';
|
||||
import { productsApi } from '@/api/products';
|
||||
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 ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 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,
|
||||
deliveryList: (params: Record<string, unknown>) =>
|
||||
[...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: {
|
||||
|
||||
Reference in New Issue
Block a user