Files
daily_console_web/src/features/store-admin/pages/SalesPage.tsx
2026-10-08 15:09:36 +05:30

1538 lines
56 KiB
TypeScript

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<Tab, string> = {
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:<billno>`, 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<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('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<WindowFilterKey>('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<StatusKey, number>;
}, [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 (
<VStack gap={3}>
<PageHeader
title="Sales"
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
isTabsInline
actions={
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* 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 : (
<WindowFilterSelect value={windowFilter} onChange={setWindowFilter} />
)}
<SearchInput
label="Search sales"
value={keyword}
onChange={setKeyword}
placeholder={SEARCH_HINT[tab]}
/>
</div>
}
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. */
<TabBar aria-label="Sales views">
<Tab
label="Orders"
icon={<ShoppingCart size={14} />}
count={allOrders.length || undefined}
isActive={tab === 'orders'}
onClick={() => setTab('orders')}
/>
<Tab
label="Deliveries"
icon={<Truck size={14} />}
count={allDeliveries.length || undefined}
isActive={tab === 'deliveries'}
onClick={() => setTab('deliveries')}
/>
<Tab
label="Counter sales"
icon={<Receipt size={14} />}
count={billRows.length || undefined}
isActive={tab === 'counter'}
onClick={() => setTab('counter')}
isGroupStart
/>
</TabBar>
}
/>
{/* ── Orders ──────────────────────────────────────────────────────── */}
{tab === 'orders' ? (
<>
<div className="kpi-grid">
<KpiCard
label="Order value"
value={money(orderTotals.value)}
note={`${plural(orderRows.length, 'order')}`}
tone="accent"
icon={<ShoppingCart size={15} />}
/>
{/* 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. */}
<KpiCard
label="Items"
value={count(orderTotals.qty)}
note="across the list"
tone="neutral"
icon={<Package size={15} />}
/>
<KpiCard
label="Average order"
value={money(orderRows.length > 0 ? orderTotals.value / orderRows.length : 0)}
note="in this view"
tone="neutral"
icon={<Calculator size={15} />}
/>
</div>
<VStack gap={1.5}>
<FilterRow
tabs={statusTabs}
status={status}
counts={tabCounts}
onStatus={setStatus}
/>
{/* 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 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
released={released}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null}
<OrdersTable
rows={orderRows}
queryKey={queryKey}
isLoading={orders.isLoading}
showBranch={selected === null}
selection={picked}
branchOf={branchOf}
assigned={assigned}
released={released}
stages={stages}
onOpen={(row) => setDetail({ row, kind: 'order' })}
/>
{orderRows.length > 0 ? (
<TotalsBar
label={`${orderRows.length} order${orderRows.length === 1 ? '' : 's'}`}
chips={[
{ label: 'Value', value: money(orderTotals.value), tone: 'accent' as const },
]}
/>
) : null}
</VStack>
</>
) : null}
{/* ── Deliveries ──────────────────────────────────────────────────── */}
{tab === 'deliveries' ? (
<>
<div className="kpi-grid">
<KpiCard
label="Jobs"
value={count(deliveryRows.length)}
note="in this view"
tone="accent"
icon={<Truck size={15} />}
/>
<KpiCard
label="Unassigned"
value={count(deliveryTotals.unassigned)}
note="no rider yet"
tone={deliveryTotals.unassigned > 0 ? 'warning' : 'success'}
icon={<UserX size={15} />}
/>
<KpiCard
label="Job value"
value={money(deliveryTotals.value)}
note="what these deliveries carry"
tone="neutral"
icon={<IndianRupee size={15} />}
/>
<KpiCard
label="Rider charges"
value={money(deliveryTotals.charges)}
note="what the riders are paid"
tone="neutral"
icon={<Bike size={15} />}
/>
</div>
<VStack gap={1.5}>
<FilterRow
tabs={statusTabs}
status={status}
counts={tabCounts}
onStatus={setStatus}
/>
<DeliveriesTable
rows={deliveryRows}
queryKey={queryKey}
isLoading={deliveries.isLoading}
showBranch={selected === null}
onOpen={(row) => setDetail({ row, kind: 'delivery' })}
/>
{deliveryRows.length > 0 ? (
<TotalsBar
label={`${deliveryRows.length} job${deliveryRows.length === 1 ? '' : 's'}`}
chips={[
{ label: 'Rider charges', value: money(deliveryTotals.charges), tone: 'error' },
{ label: 'Job value', value: money(deliveryTotals.value), tone: 'success' },
]}
/>
) : null}
</VStack>
</>
) : null}
{/* ── Counter sales ───────────────────────────────────────────────── */}
{tab === 'counter' ? (
<>
<div className="kpi-grid">
<KpiCard
label="Counter revenue"
value={money(counterTotals.gross)}
note={`${plural(counterTotals.bills, 'bill')}`}
tone="success"
icon={<Receipt size={15} />}
/>
<KpiCard
label="Average bill"
value={money(counterTotals.bills > 0 ? counterTotals.gross / counterTotals.bills : 0)}
note="per transaction"
tone="neutral"
icon={<Calculator size={15} />}
/>
<KpiCard
label="Tax collected"
value={money(counterTotals.tax)}
note="on counter sales"
tone="neutral"
icon={<Percent size={15} />}
/>
<KpiCard
label="Bills listed"
value={count(billRows.length)}
note={
counterTotals.bills > billRows.length
? `of ${count(counterTotals.bills)} — newest page`
: 'all of them'
}
tone="neutral"
icon={<ReceiptText size={15} />}
/>
</div>
<VStack gap={1.5}>
{/* 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. */}
<BillsTable
rows={billRows}
queryKey={queryKey}
isLoading={billPages.some((page) => page.isLoading)}
showBranch={selected === null}
/>
</VStack>
</>
) : 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={freshRow(detail, allOrders, allDeliveries)}
kind={detail.kind}
stages={stages}
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;
}
/* ── 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<StatusKey, number>;
onStatus: (key: StatusKey) => void;
}) {
return (
<TabBar size="sm" aria-label="Filter by status">
{tabs.map((entry) => (
<Tab
key={entry.key}
label={entry.label}
count={counts[entry.key]}
isActive={status === entry.key}
onClick={() => onStatus(entry.key)}
/>
))}
</TabBar>
);
}
/* ── 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<typeof useSelection>;
branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
/** Where each order's delivery has got to — see `orderProgress.ts`. */
stages: ReadonlyMap<number, string>;
onOpen: (row: OrderRow) => void;
}) {
// Above the early returns — a hook cannot be called conditionally.
const paged = usePaged(rows, { resetKey: queryKey });
if (isLoading) return <Loading what="orders" />;
if (rows.length === 0) {
return (
<Empty
icon={<Package size={22} />}
title="No orders here"
body="Nothing matches this date range, status or search."
/>
);
}
return (
<Panel
title="Orders"
count={paged.total}
countLabel="orders"
footer={<TablePager paged={paged} label="orders" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
{/* The tick column, first — the old console's position, and the
one place a person looks for it. */}
<th className="tick-cell" style={{ borderBottom: '1px solid var(--color-line)' }}>
<CheckboxInput
label="Select every order that can be assigned"
isLabelHidden
size="sm"
value={selection.allChosen ? true : selection.someChosen ? 'indeterminate' : false}
onChange={selection.toggleAll}
/>
</th>
<Th>#</Th>
<Th>Order</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Pickup</Th>
<Th>Drop</Th>
<Th>Qty</Th>
<Th>Amount</Th>
<Th>Window</Th>
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
{/* 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 (
<Row key={row.orderheaderid} onClick={() => 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. */}
<td
className="tick-cell"
onClick={(event) => event.stopPropagation()}
style={{
borderBottom:
'1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
verticalAlign: 'top',
paddingTop: 11,
}}
>
<CheckboxInput
label={`Select order ${row.orderid || row.orderheaderid}`}
isLabelHidden
size="sm"
value={isPicked}
/* Disabled rather than absent, so the column stays a
column and the row explains itself on hover instead of
leaving a silent gap. */
isDisabled={!verdict.canAssign}
disabledMessage={verdict.reason}
onChange={() => selection.toggle(row.orderheaderid)}
/>
</td>
<Td isMuted>
<Mono>{paged.from + index}</Mono>
</Td>
<Td>
<Primary>
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
</Primary>
<Secondary>{stamp(row.orderdate)}</Secondary>
</Td>
{showBranch ? (
<Td>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 12,
}}
>
<MapPin size={11} />
{row.applocation || branchLabel(row.locationname)}
</span>
{row.applocation ? (
<Secondary>{branchLabel(row.locationname)}</Secondary>
) : null}
</Td>
) : null}
<Td>
<Primary>{row.pickupcustomer || branchLabel(row.locationname)}</Primary>
<Secondary>{row.pickupsuburb || row.pickupaddress}</Secondary>
</Td>
<Td>
<Primary>{row.deliverycustomer || '—'}</Primary>
<Secondary>{row.deliverysuburb || row.deliveryaddress}</Secondary>
</Td>
<Td isMuted>
<Mono>{orderQuantity(row) || '—'}</Mono>
</Td>
<Td isStrong={amount > 0} isMuted={amount === 0}>
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
</Td>
<WindowCell row={row} />
<Td>
{/* 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. */}
<Chip
label={stage.status}
color={statusColor(stage.isDelivery ? DELIVERY_STATUS : ORDER_STATUS, stage.status)}
/>
</Td>
<Td>
<ViewHint />
</Td>
</Row>
);
})}
</tbody>
</table>
</div>
</Panel>
);
}
/* ── 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 <Loading what="deliveries" />;
if (rows.length === 0) {
return (
<Empty
icon={<Truck size={22} />}
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 (
<Panel
title="Deliveries"
count={paged.total}
countLabel="deliveries"
footer={<TablePager paged={paged} label="deliveries" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Order</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Pickup</Th>
<Th>Drop</Th>
<Th>Rider</Th>
<Th>KM</Th>
<Th>Charge / value</Th>
<Th>Window</Th>
<Th>Status</Th>
<Th>Notes</Th>
</tr>
</thead>
<tbody>
{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 (
<Row key={row.deliveryid} onClick={() => onOpen(row)}>
<Td>
<Primary>
<Mono>{row.orderid || `DLV-${row.deliveryid}`}</Mono>
</Primary>
{/* Measured against the order own time: deliverydate
arrives 5:30 early on every row - see stampAfterMs. */}
<Secondary>
{stamp(stampAfterMs(row.assigntime || row.deliverydate, row.deliverydate))}
</Secondary>
</Td>
{showBranch ? (
<Td isMuted>
{branchLabel(row.locationname)}
</Td>
) : null}
<Td>
<Primary w={124}>{pickupName || pickupAddr || '—'}</Primary>
<Secondary w={124}>{pickupName ? pickupAddr : ''}</Secondary>
</Td>
<Td>
<Primary w={124}>{dropName || dropAddr || '—'}</Primary>
<Secondary w={124}>{dropName ? dropAddr : ''}</Secondary>
</Td>
<Td>
{row.ridername ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
maxWidth: '100%',
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: 999,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'color-mix(in oklab, #8b5cf6 14%, transparent)',
color: '#8b5cf6',
}}
>
<Bike size={12} />
</span>
<span
style={{
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{row.ridername}
</span>
</span>
) : (
<span style={{ color: 'var(--color-ink-4)', fontStyle: 'italic' }}>
Unassigned
</span>
)}
</Td>
{/* 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. */}
<Td>
<Pill color="#ef4444">{planned ? planned.toFixed(1) : '—'}</Pill>
{actual > 0 ? <Pill color="#10b981">{actual.toFixed(1)}</Pill> : null}
</Td>
<Td>
{charge > 0 ? <Pill color="#ef4444">{moneyExact(charge)}</Pill> : null}
{value > 0 ? <Pill color="#10b981">{moneyExact(value)}</Pill> : null}
{charge === 0 && value === 0 ? (
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
) : null}
</Td>
<WindowCell row={row} />
<Td>
<Chip
label={row.orderstatus}
color={statusColor(DELIVERY_STATUS, row.orderstatus)}
/>
</Td>
<Td isMuted>
<span
title={notes}
style={{
display: 'block',
maxWidth: 76,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{notes || '—'}
</span>
</Td>
</Row>
);
})}
</tbody>
</table>
</div>
</Panel>
);
}
/* ── 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 <Loading what="counter bills" />;
if (rows.length === 0) {
return (
<Empty
icon={<Receipt size={22} />}
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 (
<Panel
title="Counter sales"
count={paged.total}
countLabel="bills"
footer={<TablePager paged={paged} label="bills" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Invoice</Th>
<Th>Time</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Till</Th>
<Th>Cashier</Th>
<Th>Customer</Th>
<Th>Paid by</Th>
<Th>Items</Th>
<Th>Tax</Th>
<Th>Total</Th>
</tr>
</thead>
<tbody>
{/* 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 (
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
<Td>
<Primary>
<Mono>{bill.invoicenumber || `#${bill.posorderid}`}</Mono>
</Primary>
<Secondary>
{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 ? (
<span style={{ color: 'var(--color-warning, #b7860b)' }}>
{' '}
· synced {shortAge(lag)} late
</span>
) : null}
</Secondary>
</Td>
<Td isMuted>
{clockOf(billedAt ?? bill.billedat)}
</Td>
{showBranch ? (
<Td isMuted>
{branchLabel(branch)}
</Td>
) : null}
<Td isMuted>
<Mono>{bill.terminalid || '—'}</Mono>
</Td>
<Td isMuted>
{bill.cashiername || '—'}
</Td>
<Td isMuted>
{bill.customername || bill.customermobile || 'Walk-in'}
</Td>
<Td>
<Chip
label={bill.paymentmode || 'n/a'}
color={bill.paymentmode ? 'var(--color-brand)' : 'var(--color-ink-4)'}
/>
</Td>
<Td isMuted>
<Mono>{count(bill.itemcount)}</Mono>
</Td>
<Td isMuted>
<Mono>{moneyExact(bill.taxamount)}</Mono>
</Td>
<Td isStrong>
<Mono>{moneyExact(bill.total)}</Mono>
</Td>
</tr>
);
})}
</tbody>
</table>
</div>
</Panel>
);
}
/* ── 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 (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
textTransform: 'capitalize',
whiteSpace: 'nowrap',
color,
background: `color-mix(in oklab, ${color} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
}}
>
{label || '—'}
</span>
);
}
/** A small outlined number — used in pairs for planned/actual. */
function Pill({ color, children }: { color: string; children: React.ReactNode }) {
return (
<span
style={{
display: 'block',
// Centred with the rest of the table, not pushed to the cell's edge.
margin: '0 auto',
width: 'fit-content',
minWidth: 62,
textAlign: 'center',
borderRadius: 8,
padding: '1px 7px',
marginBottom: 2,
fontSize: 11.5,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color,
background: `color-mix(in oklab, ${color} 10%, transparent)`,
}}
>
{children}
</span>
);
}
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 (
<HStack justify="between" align="center" wrap="wrap" gap={1}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
Totals · {label}
</Text>
<HStack gap={1} wrap="wrap">
{chips.map((chip) => (
<span
key={chip.label}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderRadius: 10,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
padding: '4px 10px',
fontSize: 12,
}}
>
<span style={{ color: 'var(--color-ink-4)' }}>{chip.label}</span>
<span
style={{
fontWeight: 700,
color: COLOR[chip.tone],
fontVariantNumeric: 'tabular-nums',
}}
>
{chip.value}
</span>
</span>
))}
</HStack>
</HStack>
);
}
/* 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 (
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading {what}…
</Text>
</VStack>
</Card>
);
}
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 380 }}>
{body}
</Text>
</VStack>
</Card>
);
}
const Mono = ({ children }: { children: React.ReactNode }) => (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{children}</span>
);
const Primary = ({ children, w = 160 }: { children: React.ReactNode; w?: number }) => (
<span
style={{
display: 'block',
fontWeight: 600,
maxWidth: w,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{children}
</span>
);
const Secondary = ({ children, w = 160 }: { children: React.ReactNode; w?: number }) =>
children ? (
<span
style={{
display: 'block',
fontSize: 11.5,
color: 'var(--color-ink-4)',
maxWidth: w,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{children}
</span>
) : null;
const ViewHint = () => (
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-brand)' }}>View →</span>
);
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 (
<tr
onClick={onClick}
tabIndex={0}
role="button"
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
}}
style={{
cursor: 'pointer',
...(isPicked ? { background: 'var(--color-brand-tint)' } : {}),
}}
>
{children}
</tr>
);
}
function Th({ children }: { children?: React.ReactNode; }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
verticalAlign: 'top',
}}
>
{children}
</td>
);
}
/* ── 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 (
<Td isMuted>
<span>—</span>
</Td>
);
}
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 (
<Td>
<span
title={hours ?? undefined}
style={isLate ? { color: 'var(--color-error, #d64545)', fontWeight: 600 } : undefined}
>
{label}
</span>
</Td>
);
}