586 lines
22 KiB
TypeScript
586 lines
22 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { ArrowDown, Bike, Check, Clock, MapPin, Phone, X } from 'lucide-react';
|
|
import type { DeliveryRow, OrderItem, OrderRow } from '@/api/types';
|
|
import { orderStage, type Stage } from './orderProgress';
|
|
import { useDeliveryMoves } from './DeliveryProgress';
|
|
import { Drawer } from './Drawer';
|
|
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
|
import { branchLabel, moneyExact } from './format';
|
|
import { lineTotal, qtyLabel, summarise } from './orderItems';
|
|
import { useOrderItems } from '@/queries/hooks';
|
|
import {
|
|
DELIVERY_STATUS,
|
|
ORDER_STATUS,
|
|
orderQuantity,
|
|
orderValue,
|
|
stampAfterMs,
|
|
statusColor,
|
|
} from './orderStatus';
|
|
|
|
type Row_ = OrderRow | DeliveryRow;
|
|
|
|
export type RowKind = 'order' | 'delivery';
|
|
|
|
/**
|
|
* The five stages an order walks, taken from the old console verbatim.
|
|
*
|
|
* The middle one matters and is easy to drop: **Packed & Ready** sits between
|
|
* confirmation and pickup, and it is the stage where an order stalls when a
|
|
* shop is busy. A four-step timeline that jumps from Confirmed to Out for
|
|
* Delivery hides exactly the delay an operator is trying to find.
|
|
*/
|
|
const STEPS: { label: string; fields: string[] }[] = [
|
|
// A delivery job has no `orderdate` of its own — its clock starts at
|
|
// `deliverydate`. Falling back keeps the first step from reading as a missing
|
|
// stamp on every delivery ever opened.
|
|
{ label: 'Order placed', fields: ['orderdate', 'deliverydate'] },
|
|
{ label: 'Confirmed', fields: ['starttime', 'assigntime'] },
|
|
{ label: 'Packed & ready', fields: ['packtime', 'arrivaltime'] },
|
|
{ label: 'Out for delivery', fields: ['pickuptime'] },
|
|
{ label: 'Delivered', fields: ['deliverytime'] },
|
|
];
|
|
|
|
/**
|
|
* One order or delivery, in full.
|
|
*
|
|
* A right-hand sheet rather than a modal: the operator is comparing this row
|
|
* against the list behind it — "is this the one that stalled?" — and a modal
|
|
* that greys the list out removes the thing being compared against.
|
|
*/
|
|
export function OrderDetailDrawer({
|
|
row,
|
|
kind,
|
|
stages,
|
|
onClose,
|
|
}: {
|
|
row: Row_;
|
|
kind: RowKind;
|
|
/**
|
|
* Where each order's delivery has got to — see `orderProgress.ts`.
|
|
*
|
|
* Passed in rather than looked up, so the drawer and the table it opened
|
|
* from cannot disagree: the row said "Picked" and the drawer said "Pending"
|
|
* would be exactly the table/drawer mismatch this console has been cleaning
|
|
* up. Optional because the dispatch board opens deliveries directly, and a
|
|
* delivery row already carries its own stage.
|
|
*/
|
|
stages?: ReadonlyMap<number, string>;
|
|
onClose: () => void;
|
|
}) {
|
|
/*
|
|
Split before the hook, not inside it.
|
|
|
|
The moves hook drives both the footer buttons and the caption that
|
|
reports their errors, and it can only be called when there IS a delivery.
|
|
Calling it twice — once per renderer — would give each its own pending state
|
|
and its own error, so a failed write would be reported where nobody is
|
|
looking. One instance, passed to both.
|
|
*/
|
|
return kind === 'delivery' ? (
|
|
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
|
|
) : (
|
|
<Sheet row={row} isDelivery={false} stage={orderStage(row as OrderRow, stages ?? EMPTY_STAGES)} onClose={onClose} />
|
|
);
|
|
}
|
|
|
|
function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => void }) {
|
|
const moves = useDeliveryMoves(job);
|
|
return (
|
|
<Sheet
|
|
row={job}
|
|
isDelivery
|
|
onClose={onClose}
|
|
{...(moves.isSettled || moves.isStalled ? {} : { footer: <MoveActions moves={moves} /> })}
|
|
caption={<MoveCaption moves={moves} />}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type Moves = ReturnType<typeof useDeliveryMoves>;
|
|
|
|
/** A drawer opened without the index falls back to the order's own word. */
|
|
const EMPTY_STAGES: ReadonlyMap<number, string> = new Map();
|
|
|
|
function Sheet({
|
|
row,
|
|
isDelivery,
|
|
stage,
|
|
onClose,
|
|
footer,
|
|
caption,
|
|
}: {
|
|
row: Row_;
|
|
/** For an order: the stage its delivery reports. Absent on a delivery row,
|
|
whose `orderstatus` IS the delivery status already. */
|
|
stage?: Stage;
|
|
/** Passed explicitly, never inferred from the presence of a footer — a
|
|
settled job has no actions and would otherwise read as an order. */
|
|
isDelivery: boolean;
|
|
onClose: () => void;
|
|
footer?: ReactNode;
|
|
caption?: ReactNode;
|
|
}) {
|
|
const delivery = isDelivery;
|
|
// Narrowed once, here. `kind` comes from the table the row was clicked in, so
|
|
// it is authoritative in a way that sniffing fields is not — a delivery row
|
|
// carries an `orderheaderid` too, which is what made the first attempt at
|
|
// this discriminator silently mis-classify every delivery.
|
|
const job = delivery ? (row as DeliveryRow) : undefined;
|
|
const order = delivery ? undefined : (row as OrderRow);
|
|
|
|
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
|
|
/* An order shows the stage its delivery reports, which may be a word from the
|
|
delivery ladder — so the ladder follows the word, not the row kind. */
|
|
const status = stage ? stage.status : row.orderstatus;
|
|
const colour = statusColor(delivery || stage?.isDelivery ? DELIVERY_STATUS : ORDER_STATUS, status);
|
|
|
|
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
|
|
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
|
|
|
/*
|
|
* What is in the order, read once here and used twice.
|
|
*
|
|
* The three figures at the top of this sheet — value, cash to collect, items
|
|
* — came only from the row the list handed over, and the list does not carry
|
|
* them on every order: order 1151-1 opened with "Value —", "Cash to collect
|
|
* —", "Items —" while the section at the bottom of the same sheet correctly
|
|
* reported one item. Two blocks describing the same order, disagreeing,
|
|
* because one of them had asked Fiesta and the other had not.
|
|
*
|
|
* So the detail read fills the gaps. The row wins when it has a figure — it
|
|
* is the number the table behind this sheet is showing, and the two must not
|
|
* differ — and this answers only where the row was silent.
|
|
*
|
|
* One fetch: the same query key, passed down rather than called again.
|
|
*/
|
|
const detail = useOrderItems(row.orderheaderid);
|
|
const items = detail.data?.items ?? [];
|
|
const detailAmount = detail.data?.amount ?? 0;
|
|
// Units, not lines, because this metric sits beside a money figure and reads
|
|
// as "how much is in it". The Items section below names both.
|
|
const itemCount =
|
|
(job ? job.itemcount : orderQuantity(row)) || Math.round(summarise(items).units);
|
|
|
|
return (
|
|
<Drawer
|
|
title={title}
|
|
isTitleMono
|
|
onClose={onClose}
|
|
meta={
|
|
<>
|
|
<Badge label={status || '—'} colour={colour} />
|
|
<span className="drawer-meta-text">{branchLabel(row.locationname)}</span>
|
|
</>
|
|
}
|
|
{...(footer ? { footer } : {})}
|
|
>
|
|
{/* ── Money ─────────────────────────────────────────────────────────
|
|
Three equal columns, the shape the spec asks for and the one that
|
|
answers "what is this worth, who pays what, how much of it". */}
|
|
<Metrics>
|
|
{/* The row's figure where it has one, Fiesta's where it does not. An
|
|
order the list reported as worth nothing is usually an order the
|
|
list was not told about, not a free one. */}
|
|
<Metric
|
|
label="Value"
|
|
value={value > 0 ? moneyExact(value) : detailAmount > 0 ? moneyExact(detailAmount) : '—'}
|
|
/>
|
|
{/* A delivery shows what the rider is paid. An order has no second
|
|
money figure worth the space: "Cash to collect" stood here and read
|
|
`collectionamt`, which Fiesta does not have, so it was a dash on
|
|
every order ever opened. See `pages/SalesPage.tsx` for why it cannot
|
|
be derived from `paymenttype` either. */}
|
|
{job ? (
|
|
<Metric
|
|
label="Rider charge"
|
|
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
|
|
isSmall
|
|
/>
|
|
) : null}
|
|
{/* Counted from the lines when the row carries no count. `itemcount`
|
|
and `quantity` are both routinely absent on the list read, which is
|
|
what made this a dash on an order that plainly had something in it. */}
|
|
<Metric label="Items" value={itemCount > 0 ? String(itemCount) : '—'} isSmall />
|
|
</Metrics>
|
|
|
|
<Section title="Order journey">
|
|
<Timeline row={row} isCancelled={cancelled} />
|
|
</Section>
|
|
|
|
<Section title="Route">
|
|
<DrawerCard>
|
|
<Party
|
|
kind="Pickup"
|
|
name={row.pickupcustomer || branchLabel(row.locationname)}
|
|
detail={
|
|
job
|
|
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
|
|
: order?.pickupaddress || order?.pickupsuburb
|
|
}
|
|
phone={row.pickupcontactno}
|
|
/>
|
|
<div className="drawer-party-link" aria-hidden>
|
|
<ArrowDown size={13} />
|
|
</div>
|
|
<Party
|
|
kind="Drop"
|
|
name={row.deliverycustomer}
|
|
detail={row.deliveryaddress || row.deliverysuburb}
|
|
phone={row.deliverycontactno}
|
|
/>
|
|
</DrawerCard>
|
|
</Section>
|
|
|
|
{/* ── Rider ─────────────────────────────────────────────────────────── */}
|
|
{job ? (
|
|
<Section title="Rider">
|
|
{job.ridername ? (
|
|
<DrawerCard>
|
|
<div className="drawer-party">
|
|
<span className="drawer-party-icon">
|
|
<Bike size={15} />
|
|
</span>
|
|
<div className="drawer-party-body">
|
|
<span className="drawer-party-name">{job.ridername}</span>
|
|
{job.ridercontact ? (
|
|
<a className="drawer-party-phone" href={`tel:${job.ridercontact}`}>
|
|
<Phone size={12} />
|
|
{job.ridercontact}
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{/* Planned against actual. The gap is the number worth reading —
|
|
a job quoted 3km and ridden 9 is either a bad address or a
|
|
rider taking a detour, and one figure hides both. */}
|
|
<Metrics cols={job.transitminutes ? 3 : 2}>
|
|
<Metric label="Planned" value={km(job.kms)} isSmall />
|
|
<Metric label="Actual" value={km(job.actualkms || job.riderkms)} isSmall />
|
|
{job.transitminutes ? (
|
|
<Metric label="Transit" value={`${job.transitminutes} min`} isSmall />
|
|
) : null}
|
|
</Metrics>
|
|
</DrawerCard>
|
|
) : (
|
|
<Note>No rider assigned yet.</Note>
|
|
)}
|
|
{/* The caption for the footer's actions. It stays in the body while
|
|
the buttons sit in the bar — it explains when to use them, which
|
|
is reading matter, not a control. */}
|
|
{caption}
|
|
</Section>
|
|
) : order?.rider ? (
|
|
<Section title="Rider">
|
|
<DrawerCard>
|
|
<Row
|
|
label="Rider"
|
|
value={order.rider}
|
|
icon={<Bike size={14} />}
|
|
/>
|
|
{order.ridercontactno ? (
|
|
<Row label="Contact" value={order.ridercontactno} icon={<Phone size={14} />} />
|
|
) : null}
|
|
</DrawerCard>
|
|
</Section>
|
|
) : null}
|
|
|
|
{/* The window the customer asked for.
|
|
|
|
Shown only when one was chosen: most orders have none, and an empty
|
|
"Delivery window —" row on every one of them would be noise that
|
|
hides the orders where it matters. */}
|
|
{'deliveryslotdate' in row && (row as { deliveryslotdate?: string }).deliveryslotdate ? (
|
|
<Section title="Delivery window">
|
|
<DrawerCard tone="subtle">
|
|
<Row
|
|
label="Requested"
|
|
value={(row as { deliveryslotdate?: string }).deliveryslotdate}
|
|
icon={<Clock size={14} />}
|
|
/>
|
|
</DrawerCard>
|
|
</Section>
|
|
) : null}
|
|
|
|
{row.ordernotes || job?.notes ? (
|
|
<Section title="Notes">
|
|
<DrawerCard tone="subtle">
|
|
<Row label="Note" value={row.ordernotes || job?.notes} isStacked />
|
|
</DrawerCard>
|
|
</Section>
|
|
) : null}
|
|
|
|
<OrderItems items={items} isLoading={detail.isLoading} isError={detail.isError} />
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
/* ── Recording progress by hand ──────────────────────────────────────────── */
|
|
|
|
/**
|
|
* The moves, in the drawer's fixed action bar.
|
|
*
|
|
* In the footer rather than beside the status chip: 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. It is still the drawer's primary action, which is why it is
|
|
* pinned rather than buried at the end of a scroll.
|
|
*/
|
|
function MoveActions({ moves: state }: { moves: Moves }) {
|
|
const { isPending, moves, status, run } = state;
|
|
|
|
return (
|
|
<>
|
|
{moves.map((step) => (
|
|
<DrawerButton
|
|
key={step.to}
|
|
label={step.label}
|
|
variant={step.to === 'cancelled' ? 'danger' : step.to === 'delivered' ? 'primary' : 'secondary'}
|
|
isDisabled={isPending || step.to === status}
|
|
onClick={() => run(step.to, step.stamp)}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MoveCaption({ moves }: { moves: Moves }) {
|
|
const { isSettled, isStalled, problem, status } = moves;
|
|
|
|
if (isSettled || isStalled) {
|
|
return <Note>This job is {status}. Nothing further to record.</Note>;
|
|
}
|
|
if (problem) {
|
|
return (
|
|
<div className="drawer-note" role="alert" style={{ color: 'var(--color-error)' }}>
|
|
<X size={15} />
|
|
<span>{problem}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<Note>
|
|
Use the buttons below only when the rider's app has not. Marking a job delivered here also
|
|
closes the order.
|
|
</Note>
|
|
);
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* The journey, as stamps rather than as a guess.
|
|
*
|
|
* A step is complete only when its timestamp is present. Nothing is inferred
|
|
* from the status: an order marked Delivered with no `pickuptime` really does
|
|
* have a missing stamp, and drawing that step as done would paper over the
|
|
* data-quality problem the operator opened this sheet to find.
|
|
*/
|
|
function Timeline({ row, isCancelled }: { row: Row_; isCancelled: boolean }) {
|
|
const fields = row as unknown as Record<string, string | undefined>;
|
|
/* Every later stamp is measured against when the order was placed, because no
|
|
step of the journey can have happened before it — see `stampAfterMs`.
|
|
`getorders` returns a `deliverydate` 5:30 BEHIND `orderdate` on every row,
|
|
and drawn raw this timeline read backwards. */
|
|
const placed = fields['orderdate'];
|
|
|
|
const steps = STEPS.map((step) => {
|
|
// Read by name: the two row shapes do not carry the same stamps — a
|
|
// delivery has no `packtime`, an order has no `arrivaltime` — so a step
|
|
// whose field is absent simply reads undefined and renders as not-yet.
|
|
const raw = step.fields.map((field) => fields[field]).find(Boolean);
|
|
const at = stampAfterMs(raw, placed);
|
|
return { label: step.label, at: at === null ? undefined : at };
|
|
});
|
|
|
|
/* The step the job is sitting on: the first one without a stamp. Marked as
|
|
"current" rather than merely "not done", so the eye lands on where the
|
|
order actually is. A finished journey has none, which is correct. */
|
|
const current = isCancelled ? -1 : steps.findIndex((step) => !step.at);
|
|
|
|
return (
|
|
<div className="drawer-timeline">
|
|
{steps.map((step, index) => {
|
|
const done = Boolean(step.at);
|
|
const isCurrent = index === current;
|
|
const isLast = index === steps.length - 1 && !isCancelled;
|
|
return (
|
|
<div key={step.label} className="drawer-step">
|
|
<span className="drawer-step-rail">
|
|
<span
|
|
className="drawer-step-dot"
|
|
data-done={done}
|
|
{...(isCurrent ? { 'data-current': 'true' } : {})}
|
|
>
|
|
{done ? <Check size={11} /> : null}
|
|
</span>
|
|
{!isLast ? <span className="drawer-step-line" data-done={done} /> : null}
|
|
</span>
|
|
<span className="drawer-step-body">
|
|
<span
|
|
className="drawer-step-label"
|
|
data-done={done}
|
|
{...(isCurrent ? { 'data-current': 'true' } : {})}
|
|
>
|
|
{step.label}
|
|
</span>
|
|
{step.at ? <span className="drawer-step-at">{when(step.at)}</span> : null}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{isCancelled ? (
|
|
<div className="drawer-step">
|
|
<span className="drawer-step-rail">
|
|
<span className="drawer-step-dot" data-tone="danger">
|
|
<X size={11} />
|
|
</span>
|
|
</span>
|
|
<span className="drawer-step-body">
|
|
<span className="drawer-step-label" style={{ color: 'var(--color-error)', fontWeight: 600 }}>
|
|
Cancelled
|
|
</span>
|
|
{row.canceltime ? <span className="drawer-step-at">{when(row.canceltime)}</span> : null}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Party({
|
|
kind,
|
|
name,
|
|
detail,
|
|
phone,
|
|
}: {
|
|
kind: string;
|
|
name: string | undefined;
|
|
detail: string | undefined;
|
|
phone: string | undefined;
|
|
}) {
|
|
return (
|
|
<div className="drawer-party">
|
|
<span className="drawer-party-icon">
|
|
<MapPin size={15} />
|
|
</span>
|
|
<div className="drawer-party-body">
|
|
<span className="drawer-party-kind">{kind}</span>
|
|
<span className="drawer-party-name">{name || '—'}</span>
|
|
{detail ? <span className="drawer-party-detail">{detail}</span> : null}
|
|
{phone ? (
|
|
<a className="drawer-party-phone" href={`tel:${phone}`}>
|
|
<Phone size={12} />
|
|
{phone}
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const when = (at: string | number): string =>
|
|
new Date(at).toLocaleString('en-IN', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const km = (value: string | undefined): string => {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} km` : '—';
|
|
};
|
|
/* ── What is actually in the order ───────────────────────────────────────── */
|
|
|
|
/**
|
|
* The products on the order, line by line.
|
|
*
|
|
* This sheet has always been able to say what an order was WORTH and never what
|
|
* it was. The note that used to sit here said as much: Fiesta returns the
|
|
* contents from `orders/getorderdetails` and nothing called it. So an operator
|
|
* chasing a stalled delivery could see ₹840 and had to open another system to
|
|
* find out whether that was rice or ice cream — which decides whether it can
|
|
* wait an hour.
|
|
*
|
|
* Handed its data rather than fetching its own. The drawer above needs the same
|
|
* read to fill the three figures in its header, and two components asking
|
|
* separately is how the header came to say "Items —" on an order this section
|
|
* was, at that moment, listing.
|
|
*/
|
|
function OrderItems({
|
|
items,
|
|
isLoading,
|
|
isError,
|
|
}: {
|
|
items: readonly OrderItem[];
|
|
isLoading: boolean;
|
|
isError: boolean;
|
|
}) {
|
|
if (isLoading) {
|
|
return (
|
|
<Section title="Items">
|
|
<Note>Reading what is in this order…</Note>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
// Not a blank section. The rest of the sheet is sound and this one read
|
|
// failed; saying so beats an empty heading that reads like an empty order.
|
|
return (
|
|
<Section title="Items">
|
|
<Note>Could not read the items on this order.</Note>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<Section title="Items">
|
|
<DrawerCard tone="subtle">
|
|
<Row label="Contents" value="Fiesta returned no line items for this order." isStacked />
|
|
</DrawerCard>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
const { lines, units, short } = summarise(items);
|
|
|
|
return (
|
|
<Section
|
|
title={
|
|
// Both numbers when they differ, because they answer different
|
|
// questions: four products, twelve units. One labelled as the other is
|
|
// how a picker packs the wrong trolley.
|
|
units !== lines ? `Items · ${lines} products · ${qtyLabel(units)} units` : `Items · ${lines}`
|
|
}
|
|
>
|
|
{short > 0 ? (
|
|
<Note>
|
|
{short === 1 ? 'One line was' : `${short} lines were`} short-supplied — the shop could not
|
|
give the full quantity ordered.
|
|
</Note>
|
|
) : null}
|
|
|
|
<DrawerCard>
|
|
{items.map((item) => (
|
|
<Row
|
|
key={item.orderdetailid}
|
|
label={item.productname?.trim() || `Product ${item.productid}`}
|
|
value={
|
|
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 10 }}>
|
|
{/* Quantity first: a picker reads this column, not the price. */}
|
|
<span style={{ color: 'var(--color-ink-2)' }}>
|
|
{qtyLabel(item.orderqty, item.unitname)}
|
|
</span>
|
|
<span style={{ fontWeight: 600 }}>{moneyExact(lineTotal(item))}</span>
|
|
</span>
|
|
}
|
|
/>
|
|
))}
|
|
</DrawerCard>
|
|
</Section>
|
|
);
|
|
}
|