import { useState, useMemo } from 'react'; import { matchesWindow, windowHasPassed, windowHours, windowLabel, type WindowFilterKey, } from '../deliveryWindow'; import { WindowFilterSelect } from '../WindowFilterSelect'; import { useDateScope } from '@/components/shell/DateScope'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react'; import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; import { SearchInput } from '@/components/SearchInput'; import { Tab, TabBar } from '@/components/TabBar'; import { useDeliveries, useOrders, usePosBillsByBranch, usePosSalesByBranch, } from '@/queries/hooks'; import { useBranchScope } from '../BranchScope'; import { branchLabel, count, money, moneyExact, plural } from '../format'; import { DELIVERY_STATUS, ORDER_STATUS, DELIVERY_STATUS_TABS, ORDER_STATUS_TABS, matchesStatus, orderQuantity, orderValue, statusColor, billedAtMs, stampAfterMs, syncLagMs, type StatusKey, } from '../orderStatus'; import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer'; import { shortAge } from '../posStatus'; import { AssignBar } from '../AssignBar'; import { assignability, assignedFrom, releasedFrom } from '../assignDelivery'; import { deliveryStageFrom, orderStage } from '../orderProgress'; import { useSelection } from '@/components/useSelection'; import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { isCounterSale } from '../branchStats'; import './deliveries.css'; type Tab = 'orders' | 'deliveries' | 'counter'; /** * What the one search box matches, per view. * * The page used to draw three separate boxes, one inside each view, and the * only thing that differed between them was this line — the value behind them * was already a single `keyword`, filtering orders and deliveries through the * query and counter bills client-side. So the boxes were three renderings of * one control, and the placeholder is what survives of them. */ const SEARCH_HINT: Record = { orders: 'Order ID, customer…', deliveries: 'Order ID, rider…', counter: 'Invoice, till, cashier…', }; /** * The bill number a counter sale was imported under. * * The importer records it twice — `remarks` as `OFFLINE:`, and * `ordernotes` as prose — but `getorders` returns only `ordernotes`, so that * is what this reads. The shop knows the sale by the number printed on the * slip, and matching it back to their own sheet is the whole point of showing * it; the platform's `orderid` means nothing to them and is only the fallback. */ function offlineBillNumber(order: OrderRow): string { const found = /\(bill\s+(.+?)\)\s*$/i.exec(String(order.ordernotes ?? '')); return found?.[1]?.trim() || String(order.orderid ?? ''); } /** * Sales — app orders, delivery jobs and counter bills. * * Every column here comes from the old console rather than from what looked * reasonable, because the old columns encode things the data does not announce. * Three examples that a fresh design gets wrong: * * - **Amount is a fallback chain.** `ordervalue || orderamount || deliveryamt`. * Which field carries the money depends on the endpoint the row came * through, so reading one of them shows blanks on a third of the rows. * - **Deliveries are their own read.** Not a filter over orders. The delivery * row has a rider name, a planned distance AND an actual one, a rider charge * AND a job value, and its own status ladder. * * ── There is no cash-on-delivery figure, and there never was ──────────────── * * A COD column, a COD total and a "cash to collect" metric were removed on * 2026-09-28 because nothing could ever fill them. They read `collectionamt`, * a field that does not exist anywhere in Fiesta — `grep --include=*.go` * returns nothing — so all three had shown a dash or zero on every order since * the day they were written. * * It cannot be derived either, which was measured rather than assumed: across * 800 live orders spanning three tenants and two regions, including 247 * delivered ones, `orders.paymenttype` is 42 on every single row, and * `paymentstatus` is not returned by `getorders` at all. There is no signal in * the data that separates a cash order from a prepaid one. * * (The same measurement shows `getdeliveryinsight`'s `payondelivery` and * `paylater` aggregates are permanently zero — they key on paymenttype 43 and * 64, which do not occur.) * * Bringing COD back means the customer app recording the payment method and * Fiesta serving it. Until then a blank column promises a number the system * has never been able to produce, which reads as broken rather than unbuilt. */ export function SalesPage() { const { scoped, selected, branches, tenantid } = useBranchScope(); const [tab, setTab] = useState('orders'); const [status, setStatus] = useState('all'); /* Which delivery window to show. Separate from `status`, and both apply: "evening orders that are still pending" is the question a dispatcher actually asks, and folding the two into one control would make it unaskable. */ const [windowFilter, setWindowFilter] = useState('all'); const dates = useDateScope(); const [keyword, setKeyword] = useState(''); /* What the tables are currently showing. Any change to it sends every pager back to page 1 — landing on page 4 of a search you just typed reads as an empty result. */ const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`; const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>( null, ); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); // Status filtering happens client-side, not in the query. The tabs need a // count each, and asking the server per tab would be six round trips to // render one row of numbers. const query = tenantid ? { tenantid, locationid: selected ?? undefined, keyword: keyword.trim() || undefined, ...dates.range, pagesize: 200, } : undefined; const orders = useOrders(query); const deliveries = useDeliveries(query); const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 }); const posSummary = usePosSalesByBranch(branchIds, dates.range); /** * App orders and imported counter bills, told apart. * * `uploadofflinesales` writes a till's day into `orders` like any other row, * tagged `deliverytype = 'OFFLINE'`. Everything on this page treated the * whole response as online, so an imported bill was counted under "Online * Orders", added to Order value and Average order, and was missing from the * Counter sales tab that exists to show it. */ const everyOrder = orders.data ?? []; const allOrders = useMemo(() => everyOrder.filter((row) => !isCounterSale(row)), [everyOrder]); const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]); const allDeliveries = deliveries.data ?? []; /* How far each order has actually got. The order row is only ever told pending/delivered/cancelled, so the six stages in between are read off the deliveries list — same window, same query, no extra fetch. See gap D in `orderProgress.ts`. Built here, above the filter, because the filter and the tab counts now read the same resolved word the table renders. */ const stages = useMemo(() => deliveryStageFrom(allDeliveries), [allDeliveries]); /* * Filtered on the word the row actually SHOWS. * * This used to filter on `row.orderstatus` while the chip rendered * `orderStage(...)`, on the reasoning that the order strip had no Picked or * Arrived tab to put those rows in. That reasoning held the wrong end: a row * labelled Picked sat under the Pending tab, there was no Picked tab to look * in, and the counts described a set the operator could not see. The strip * carries those tabs now, so the filter follows the column. */ const orderRows = useMemo( () => allOrders.filter( (row) => matchesStatus(status, orderStage(row, stages).status) && matchesWindow(windowFilter, row), ), // windowFilter belongs here. Left out, this memo never recomputed when the // dropdown changed, so the control moved and the table did not — a filter // that silently does nothing. [allOrders, status, stages, windowFilter], ); const deliveryRows = useMemo( () => allDeliveries.filter( (row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row), ), [allDeliveries, status, windowFilter], ); const billRows = useMemo(() => { const out: { bill: PosSale; branch: string }[] = []; billPages.forEach((page, index) => { const branchId = branchIds[index]; const branch = branches.find((entry) => entry.locationid === branchId); for (const bill of page.data?.bills ?? []) { out.push({ bill, branch: branch?.locationname ?? `Branch ${branchId}` }); } }); /* Imported bills belong in this table too — they are counter sales that reached us on a spreadsheet instead of over MQTT. `terminalid` and `cashiername` are left undefined rather than filled with a placeholder: a sheet does not say which till rang the sale or who was on it, and inventing either would put a name against a transaction that has none. The table already renders a dash for what is missing. */ for (const order of importedBills) { const branch = branches.find((entry) => entry.locationid === Number(order.locationid)); out.push({ bill: { locationid: Number(order.locationid ?? 0), invoicenumber: offlineBillNumber(order), billedat: order.orderdate, businessdate: order.orderdate, total: orderValue(order), taxamount: Number(order.taxamount ?? 0), itemcount: Number(order.itemcount ?? 0), customername: order.deliverycustomer ?? '', customermobile: order.deliverycontactno ?? '', }, branch: branch?.locationname ?? order.locationname ?? `Branch ${order.locationid}`, }); } const term = keyword.trim().toLowerCase(); return out .filter(({ bill }) => term === '' ? true : `${bill.invoicenumber} ${bill.terminalid} ${bill.cashiername} ${bill.customername}` .toLowerCase() .includes(term), ) /* Sorted on the recovered instant, not the raw stamp. A string compare put the one terminal that sends correct UTC five and a half hours adrift of the rest, so newest-first was newest-first only within a terminal. */ .sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0)); }, [billPages, branchIds, branches, keyword, importedBills]); /** * The tab strip for whichever sub-tab is open. * * Orders and deliveries move through different ladders — an order is never * "arrived", a delivery is never "created" — so each gets its own tabs rather * than a shared set with half of it stuck at zero. */ const statusTabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; /** * Counts for the tab strip, from the unfiltered set. * * Read through the same resolution the rows are filtered and rendered by. A * count taken from `row.orderstatus` while the tab selects on the stage is a * number describing a different set from the one that appears when it is * pressed — Pending would say 41 and then show 6. */ const tabCounts = useMemo(() => { const statusOf = tab === 'deliveries' ? (row: DeliveryRow | OrderRow) => row.orderstatus : (row: DeliveryRow | OrderRow) => orderStage(row as OrderRow, stages).status; const source: (DeliveryRow | OrderRow)[] = tab === 'deliveries' ? allDeliveries : allOrders; const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; return Object.fromEntries( tabs.map((entry) => [ entry.key, source.filter((row) => matchesStatus(entry.key, statusOf(row))).length, ]), ) as Record; }, [tab, allOrders, allDeliveries, stages]); const counterTotals = useMemo(() => { const fromTills = posSummary.reduce( (acc, page) => ({ bills: acc.bills + (page.data?.billcount ?? 0), gross: acc.gross + (page.data?.grosssales ?? 0), tax: acc.tax + (page.data?.taxcollected ?? 0), }), { bills: 0, gross: 0, tax: 0 }, ); // Plus the days that arrived on a spreadsheet. The POS summary cannot see // these — they were never in `pos_orders` — so a shop that imports rather // than syncs had "Counter revenue ₹0" over a table of its own bills. return importedBills.reduce( (acc, order) => ({ bills: acc.bills + 1, gross: acc.gross + orderValue(order), tax: acc.tax + Number(order.taxamount ?? 0), }), fromTills, ); }, [posSummary, importedBills]); const orderTotals = useMemo(() => { const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0); const qty = orderRows.reduce((sum, row) => sum + orderQuantity(row), 0); return { value, qty }; }, [orderRows]); const deliveryTotals = useMemo(() => { const value = deliveryRows.reduce((sum, row) => sum + (row.deliveryamt ?? 0), 0); const charges = deliveryRows.reduce((sum, row) => sum + (row.deliverycharges ?? 0), 0); const unassigned = deliveryRows.filter((row) => !row.ridername).length; return { value, charges, unassigned }; }, [deliveryRows]); /* ── Assigning riders, from the orders list ───────────────────────────── */ /** * Which orders the deliveries list already accounts for. * * The order row's own `deliveryid` is the direct answer, and it is only * populated for deliveries created since the backend started writing it — * nothing wrote `orders.deliveryid` before, and the orders query aliased the * column away. Every delivery that predates that fix sits against an order * still reading 0, so without this the list would offer to assign the whole * of production's delivery history a second time. */ const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]); /* Orders whose only delivery was rejected or called off. They are waiting for a rider again, and without this the delivery row left behind keeps them out of every assign surface for good — see `releasedFrom`. */ const released = useMemo(() => releasedFrom(allDeliveries), [allDeliveries]); const branchOf = useMemo( () => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid), [branches], ); /** * The orders a rider can be put on, out of the ones currently listed. * * The selection is built from these rather than from every visible row, so * "select all" only ever ticks what can actually be sent. Handing it every * row instead ticked the disabled boxes too: they went grey-and-checked and * the bar counted more than it would assign, which is how a person stops * trusting the ticks. */ const selectable = useMemo( () => orderRows .filter((row) => assignability(row, branchOf(row), assigned, released).canAssign) .map((row) => row.orderheaderid), [orderRows, branchOf, assigned], ); const picked = useSelection(selectable); const pickedOrders = useMemo( () => orderRows.filter((row) => picked.has(row.orderheaderid)), [orderRows, picked], ); return ( {/* Not on the counter tab. Imported till bills are not deliveries and can never carry a window, so the control would be one that does nothing on a third of this page. */} {tab === 'counter' ? null : ( )} } tabs={ /* THREE views, not four. There used to be an "Online Orders" tab in front of these carrying the combined count — 86 beside Orders 85 and Deliveries 1. It was never a view of its own: it reported active whenever the page was NOT on Counter sales, and clicking it from either of the two tabs it contained did nothing at all. In the old flat pills that passed unnoticed. In a segmented control it does not: a segmented control means "one of these", and Online Orders and Orders were both drawn selected at the same time, which is simply a broken control. Nothing is lost by dropping it. Its count was the sum of the two beside it, and the combined figure is already on the KPI row above. Counter sales keeps the divider: Orders and Deliveries are two halves of the app's own trade and the till is a different channel, which is the grouping "Online Orders" was there to express. */ } count={allOrders.length || undefined} isActive={tab === 'orders'} onClick={() => setTab('orders')} /> } count={allDeliveries.length || undefined} isActive={tab === 'deliveries'} onClick={() => setTab('deliveries')} /> } count={billRows.length || undefined} isActive={tab === 'counter'} onClick={() => setTab('counter')} isGroupStart /> } /> {/* ── Orders ──────────────────────────────────────────────────────── */} {tab === 'orders' ? ( <>
} /> {/* A "Cash to collect" card stood here. It read `collectionamt`, which Fiesta does not have, so it showed ₹0 on every order ever listed. See the note at the top of this file for why it cannot be derived either. */} } /> 0 ? orderTotals.value / orderRows.length : 0)} note="in this view" tone="neutral" icon={} />
{/* Above the table, not below it and not in a drawer. Assigning is something you do WHILE reading the day's orders — glancing between the drop addresses and who is free — so the control belongs beside the list rather than on a screen you have to leave the list for. This is where the old console put it. */} {picked.count > 0 ? ( ) : null} setDetail({ row, kind: 'order' })} /> {orderRows.length > 0 ? ( ) : null} ) : null} {/* ── Deliveries ──────────────────────────────────────────────────── */} {tab === 'deliveries' ? ( <>
} /> 0 ? 'warning' : 'success'} icon={} /> } /> } />
setDetail({ row, kind: 'delivery' })} /> {deliveryRows.length > 0 ? ( ) : null} ) : null} {/* ── Counter sales ───────────────────────────────────────────────── */} {tab === 'counter' ? ( <>
} /> 0 ? counterTotals.gross / counterTotals.bills : 0)} note="per transaction" tone="neutral" icon={} /> } /> billRows.length ? `of ${count(counterTotals.bills)} — newest page` : 'all of them' } tone="neutral" icon={} />
{/* No status strip and no search row: a counter bill is rung and done, so it has no lifecycle to filter by, and the search is in the page header with the tabs — one box for all three views. */} page.isLoading)} showBranch={selected === null} /> ) : 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 ? ( setDetail(null)} /> ) : null}
); } /** * 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; } /* ── The assignment queue ────────────────────────────────────────────────── */ /** * The status filters and the search, on one line above the table. * * This row replaces the "Orders" / "Deliveries" headings that used to sit here. * They named the table the tab had already named — two words of chrome between * the numbers and the rows — while the filters sat above the KPI cards, so the * control and the list it narrowed were separated by four cards. * * Below the cards is where the filters belong: the cards summarise everything * in view, the filters decide what is in view, and the table shows it. Reading * downward now follows that order. */ /** * The status ladder, under the view tabs. * * The SECONDARY size of the same control the views use — no track, smaller * type, a brand tint on the selected one. That subordination is the point: two * strips of tabs at equal weight on one screen read as two equally important * choices, when this one only narrows whatever the strip above it selected. * * It used to be a private `StatusTab` — a 30px chip with a 12px radius, a * hairline when idle and a solid brand fill when selected — which was a fourth * treatment of "tab" on a page that now has one. * * The wrapper it sat in is gone with it. It was an `HStack justify="between"` * holding the tabs and the search; the search moved to the page header, so the * row was a space-between with nothing on the right of it. */ function FilterRow({ tabs, status, counts, onStatus, }: { /** Which ladder to show — orders and deliveries do not share one. */ tabs: readonly { readonly key: StatusKey; readonly label: string }[]; status: StatusKey; counts: Record; onStatus: (key: StatusKey) => void; }) { return ( {tabs.map((entry) => ( onStatus(entry.key)} /> ))} ); } /* ── Orders table ────────────────────────────────────────────────────────── */ function OrdersTable({ rows, isLoading, showBranch, selection, branchOf, assigned, released, stages, onOpen, queryKey, }: { rows: OrderRow[]; isLoading: boolean; /** Identifies the current filter set; changing it returns the pager to page 1. */ queryKey: string; showBranch: boolean; selection: ReturnType; branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; /** Orders whose delivery is dead — see `releasedFrom`. */ released?: ReadonlySet; /** Where each order's delivery has got to — see `orderProgress.ts`. */ stages: ReadonlyMap; onOpen: (row: OrderRow) => void; }) { // Above the early returns — a hook cannot be called conditionally. const paged = usePaged(rows, { resetKey: queryKey }); if (isLoading) return ; if (rows.length === 0) { return ( } title="No orders here" body="Nothing matches this date range, status or search." /> ); } return ( } >
{/* The tick column, first — the old console's position, and the one place a person looks for it. */} {showBranch ? : null} {/* Keyed on orderheaderid, never orderid — `orderid` is not unique in this tenant's data, a defect the old console hit first. */} {paged.rows.map((row, index) => { const amount = orderValue(row); const verdict = assignability(row, branchOf(row), assigned, released); const stage = orderStage(row, stages); const isPicked = selection.has(row.orderheaderid); return ( onOpen(row)} isPicked={isPicked}> {/* The tick swallows its own click. Reaching for a checkbox and getting a drawer instead is the fastest way to make an operator stop trusting the boxes. */} {showBranch ? ( ) : null} ); })}
# OrderBranchPickup Drop Qty Amount Window Status
event.stopPropagation()} style={{ borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)', verticalAlign: 'top', paddingTop: 11, }} > selection.toggle(row.orderheaderid)} /> {paged.from + index} {row.orderid || `#${row.orderheaderid}`} {stamp(row.orderdate)} {row.applocation || branchLabel(row.locationname)} {row.applocation ? ( {branchLabel(row.locationname)} ) : null} {row.pickupcustomer || branchLabel(row.locationname)} {row.pickupsuburb || row.pickupaddress} {row.deliverycustomer || '—'} {row.deliverysuburb || row.deliveryaddress} {orderQuantity(row) || '—'} 0} isMuted={amount === 0}> {amount > 0 ? moneyExact(amount) : '—'} {/* The stage, not the order's own word — which sits on `pending` from assignment until delivery. The ladder has to follow it: colouring `picked` with the ORDER map paints it grey, in the middle of the journey where it matters most. */}
); } /* ── Deliveries table ────────────────────────────────────────────────────── */ function DeliveriesTable({ rows, isLoading, showBranch, onOpen, queryKey, }: { rows: DeliveryRow[]; isLoading: boolean; showBranch: boolean; onOpen: (row: DeliveryRow) => void; queryKey: string; }) { const paged = usePaged(rows, { resetKey: queryKey }); if (isLoading) return ; if (rows.length === 0) { return ( } title="No deliveries here" body="Try another status, date range or search. A counter sale never appears — it is handed over at the till." /> ); } return ( } >
{showBranch ? : null} {paged.rows.map((row) => { // The backend leaves customer and contact blank on app-created // jobs but fills the address, so both sides fall back rather than // rendering an empty cell. const pickupName = branchLabel(row.pickupcustomer) || row.pickupcontactno; const pickupAddrRaw = row.pickupsuburb || row.pickuplocation || row.Pickupaddress || row.pickupaddress; // Suppress the second line when it repeats the first — a cell // reading "Peelamedu / Peelamedu" spends width to say nothing. const pickupAddr = pickupAddrRaw === pickupName ? '' : pickupAddrRaw; const dropName = row.deliverycustomer || row.deliverycontactno; const dropAddr = row.deliveryaddress || row.deliverylocation || row.deliverysuburb; const planned = Number(row.kms) || 0; const actual = Number(row.actualkms) || Number(row.riderkms) || 0; const charge = row.deliverycharges ?? 0; const value = row.deliveryamt ?? 0; const notes = row.ordernotes || row.notes; return ( onOpen(row)}> {showBranch ? ( ) : null} {/* Planned above, actual below — the pair is the point. A delivery that was quoted 3km and rode 9 is the row worth finding, and one number cannot show it. */} ); })}
OrderBranchPickup Drop Rider KM Charge / value Window Status Notes
{row.orderid || `DLV-${row.deliveryid}`} {/* Measured against the order own time: deliverydate arrives 5:30 early on every row - see stampAfterMs. */} {stamp(stampAfterMs(row.assigntime || row.deliverydate, row.deliverydate))} {branchLabel(row.locationname)} {pickupName || pickupAddr || '—'} {pickupName ? pickupAddr : ''} {dropName || dropAddr || '—'} {dropName ? dropAddr : ''} {row.ridername ? ( {row.ridername} ) : ( Unassigned )} {planned ? planned.toFixed(1) : '—'} {actual > 0 ? {actual.toFixed(1)} : null} {charge > 0 ? {moneyExact(charge)} : null} {value > 0 ? {moneyExact(value)} : null} {charge === 0 && value === 0 ? ( — ) : null} {notes || '—'}
); } /* ── Counter bills ───────────────────────────────────────────────────────── */ function BillsTable({ rows, isLoading, showBranch, queryKey, }: { rows: { bill: PosSale; branch: string }[]; isLoading: boolean; showBranch: boolean; queryKey: string; }) { const paged = usePaged(rows, { resetKey: queryKey }); if (isLoading) return ; if (rows.length === 0) { return ( } title="No counter sales in this period" body="Either nothing was rung up, or no till has reached us yet. The Console's till board tells the two apart." /> ); } return ( } >
{showBranch ? : null} {/* Keyed on terminalorderid — the UUID minted at the till. The invoice number is unique only per terminal, so a replaced till restarting its series would collide. */} {paged.rows.map(({ bill, branch }) => { const lag = syncLagMs(bill); // The recovered instant, not the stamp — see `billedAtMs`. const billedAt = billedAtMs(bill); return ( {showBranch ? ( ) : null} ); })}
Invoice TimeBranchTill Cashier Customer Paid by Items Tax Total
{bill.invoicenumber || `#${bill.posorderid}`} {bill.businessdate} {/* A bill that reached us long after it was rung means the till was offline. Worth saying on the row: a day's takings that all landed at 6pm did not happen at 6pm. */} {lag !== null ? ( {' '} · synced {shortAge(lag)} late ) : null} {clockOf(billedAt ?? bill.billedat)} {branchLabel(branch)} {bill.terminalid || '—'} {bill.cashiername || '—'} {bill.customername || bill.customermobile || 'Walk-in'} {count(bill.itemcount)} {moneyExact(bill.taxamount)} {moneyExact(bill.total)}
); } /* ── Primitives ──────────────────────────────────────────────────────────── */ /** "21 Aug, 14:32". Falls back to the raw string rather than to "Invalid Date". */ function stamp(value: string | number | null | undefined): string { if (value === null || value === undefined || value === '') return '—'; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return String(value); return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`; } /** * Takes an epoch too, so a bill can pass the instant `billedAtMs` recovered * rather than the stamp it arrived with. Formatting the raw `billedat` shifted * an already-wrong time by another +05:30 and put a 17:17 sale on the row at * 22:47 — two bugs compounding into a plausible-looking number. */ function clockOf(value: string | number | undefined): string { if (value === undefined || value === '') return '—'; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return String(value); return parsed.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false, }); } /* The page-local search box lived here. It is `components/SearchInput` now, in the header beside the tabs, and there is one of it. */ /** A status chip in the lifecycle's own colour. */ function Chip({ label, color }: { label: string | undefined; color: string }) { return ( {label || '—'} ); } /** A small outlined number — used in pairs for planned/actual. */ function Pill({ color, children }: { color: string; children: React.ReactNode }) { return ( {children} ); } function TotalsBar({ label, chips, }: { label: string; chips: { label: string; value: string; tone: 'accent' | 'success' | 'warning' | 'error' }[]; }) { const COLOR = { accent: 'var(--color-brand)', success: 'var(--color-success, #10b981)', warning: 'var(--color-warning, #b7860b)', error: 'var(--color-error, #d64545)', }; return ( Totals · {label} {chips.map((chip) => ( {chip.label} {chip.value} ))} ); } /* The page-local `StatusTab` lived here — it is the `sm` size of `components/TabBar` now. */ /* The page-local `TabButton` lived here. It is `components/TabBar` now — one control for the six pages that had each grown their own. */ function Loading({ what }: { what: string }) { return ( Reading {what}… ); } function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { return ( {icon} {title} {body} ); } const Mono = ({ children }: { children: React.ReactNode }) => ( {children} ); const Primary = ({ children, w = 160 }: { children: React.ReactNode; w?: number }) => ( {children} ); const Secondary = ({ children, w = 160 }: { children: React.ReactNode; w?: number }) => children ? ( {children} ) : null; const ViewHint = () => ( View → ); function Row({ children, onClick, isPicked, }: { children: React.ReactNode; onClick: () => void; /** Ticked rows take a brand wash, so a selection is visible while scrolling. */ isPicked?: boolean; }) { return ( { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick(); } }} style={{ cursor: 'pointer', ...(isPicked ? { background: 'var(--color-brand-tint)' } : {}), }} > {children} ); } function Th({ children }: { children?: React.ReactNode; }) { return ( {children} ); } function Td({ children, isMuted, isStrong, }: { children: React.ReactNode; isMuted?: boolean; isStrong?: boolean; }) { return ( {children} ); } /* ── Delivery window ──────────────────────────────────────────────────────── */ /** * The window a customer asked for, as a table cell. * * Most rows have none, and those read "—" rather than staying blank: an empty * cell in a table of filled ones looks like something failed to load, and this * is the ordinary case, not a failure. */ function WindowCell({ row }: { row: OrderRow | DeliveryRow }) { const label = windowLabel(row); if (!label) { return ( — ); } const hours = windowHours(row); // A window that has ended while the order is still on the board is the one // thing here worth noticing. Tinted, never hidden — burying it would hide the // row somebody most needs to act on. const isLate = windowHasPassed(row); return ( {label} ); }