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; 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' ? ( ) : ( ); } function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => void }) { const moves = useDeliveryMoves(job); return ( })} caption={} /> ); } type Moves = ReturnType; /** A drawer opened without the index falls back to the order's own word. */ const EMPTY_STAGES: ReadonlyMap = 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 ( {branchLabel(row.locationname)} } {...(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". */} {/* 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. */} 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 ? ( ) : 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. */} 0 ? String(itemCount) : '—'} isSmall />
{/* ── Rider ─────────────────────────────────────────────────────────── */} {job ? (
{job.ridername ? (
{job.ridername} {job.ridercontact ? ( {job.ridercontact} ) : null}
{/* 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. */} {job.transitminutes ? ( ) : null}
) : ( No rider assigned yet. )} {/* 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}
) : order?.rider ? (
} /> {order.ridercontactno ? ( } /> ) : null}
) : 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 ? (
} />
) : null} {row.ordernotes || job?.notes ? (
) : null}
); } /* ── 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) => ( run(step.to, step.stamp)} /> ))} ); } function MoveCaption({ moves }: { moves: Moves }) { const { isSettled, isStalled, problem, status } = moves; if (isSettled || isStalled) { return This job is {status}. Nothing further to record.; } if (problem) { return (
{problem}
); } return ( Use the buttons below only when the rider's app has not. Marking a job delivered here also closes the order. ); } /* ────────────────────────────────────────────────────────────────────────── */ /** * 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; /* 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 (
{steps.map((step, index) => { const done = Boolean(step.at); const isCurrent = index === current; const isLast = index === steps.length - 1 && !isCancelled; return (
{done ? : null} {!isLast ? : null} {step.label} {step.at ? {when(step.at)} : null}
); })} {isCancelled ? (
Cancelled {row.canceltime ? {when(row.canceltime)} : null}
) : null}
); } function Party({ kind, name, detail, phone, }: { kind: string; name: string | undefined; detail: string | undefined; phone: string | undefined; }) { return (
{kind} {name || '—'} {detail ? {detail} : null} {phone ? ( {phone} ) : null}
); } 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 (
Reading what is in this order…
); } 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 (
Could not read the items on this order.
); } if (items.length === 0) { return (
); } const { lines, units, short } = summarise(items); return (
{short > 0 ? ( {short === 1 ? 'One line was' : `${short} lines were`} short-supplied — the shop could not give the full quantity ordered. ) : null} {items.map((item) => ( {/* Quantity first: a picker reads this column, not the price. */} {qtyLabel(item.orderqty, item.unitname)} {moneyExact(lineTotal(item))} } /> ))}
); }