Files
daily_console_web/src/features/store-admin/pages/SalesPage.tsx
2026-09-10 17:57:00 +05:30

1414 lines
48 KiB
TypeScript

import { useState, useMemo } from 'react';
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 { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Banknote, 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 {
useDeliveries,
useOrders,
usePosBillsByBranch,
usePosSalesByBranch,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchLabel, count, money, moneyExact } 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 } from '../assignDelivery';
import { useSelection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import './deliveries.css';
type Tab = 'orders' | 'deliveries' | 'counter';
/**
* 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.
* - **COD only appears when there is cash to collect.** `collectionamt` on
* every row would be a column of dashes.
*/
export function SalesPage() {
const { scoped, selected, branches, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('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);
const allOrders = orders.data ?? [];
const allDeliveries = deliveries.data ?? [];
const orderRows = useMemo(
() => allOrders.filter((row) => matchesStatus(status, row.orderstatus)),
[allOrders, status],
);
const deliveryRows = useMemo(
() => allDeliveries.filter((row) => matchesStatus(status, row.orderstatus)),
[allDeliveries, status],
);
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}` });
}
});
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]);
/**
* 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. */
const tabCounts = useMemo(() => {
const source = 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, row.orderstatus)).length,
]),
) as Record<StatusKey, number>;
}, [tab, allOrders, allDeliveries]);
const counterTotals = useMemo(
() =>
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 },
),
[posSummary],
);
const orderTotals = useMemo(() => {
const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0);
const cod = orderRows.reduce((sum, row) => sum + (row.collectionamt ?? 0), 0);
const qty = orderRows.reduce((sum, row) => sum + orderQuantity(row), 0);
return { value, cod, 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]);
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).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={
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Online Orders"
icon={<ShoppingCart size={14} />}
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
isActive={tab !== 'counter'}
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
/>
{tab !== 'counter' ? (
<>
<TabButton
label="Orders"
icon={<ShoppingCart size={14} />}
badge={allOrders.length || undefined}
isActive={tab === 'orders'}
onClick={() => setTab('orders')}
/>
<TabButton
label="Deliveries"
icon={<Truck size={14} />}
badge={allDeliveries.length || undefined}
isActive={tab === 'deliveries'}
onClick={() => setTab('deliveries')}
/>
</>
) : null}
<TabButton
label="Counter sales"
icon={<Receipt size={14} />}
badge={billRows.length || undefined}
isActive={tab === 'counter'}
onClick={() => setTab('counter')}
/>
</HStack>
}
/>
{/* ── Orders ──────────────────────────────────────────────────────── */}
{tab === 'orders' ? (
<>
<div className="kpi-grid">
<KpiCard
label="Order value"
value={money(orderTotals.value)}
note={`${count(orderRows.length)} orders`}
tone="accent"
icon={<ShoppingCart size={15} />}
/>
<KpiCard
label="Cash to collect"
value={money(orderTotals.cod)}
note="COD on these orders"
tone={orderTotals.cod > 0 ? 'warning' : 'neutral'}
icon={<Banknote size={15} />}
/>
<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}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
}
/>
{/* 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}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null}
<OrdersTable
rows={orderRows}
queryKey={queryKey}
isLoading={orders.isLoading}
showBranch={selected === null}
selection={picked}
branchOf={branchOf}
assigned={assigned}
onOpen={(row) => setDetail({ row, kind: 'order' })}
/>
{orderRows.length > 0 ? (
<TotalsBar
label={`${orderRows.length} order${orderRows.length === 1 ? '' : 's'}`}
chips={[
...(orderTotals.cod > 0
? [{ label: 'COD', value: money(orderTotals.cod), tone: 'warning' as const }]
: []),
{ 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}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
}
/>
<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={`${count(counterTotals.bills)} bills`}
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 here: a counter bill is rung and done, so it
has no lifecycle to filter by. The search keeps the row. */}
<HStack justify="end" wrap="wrap">
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
</HStack>
<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}
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.
*/
function FilterRow({
tabs,
status,
counts,
onStatus,
search,
}: {
/** 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;
search: React.ReactNode;
}) {
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={0.5} wrap="wrap">
{tabs.map((entry) => (
<StatusTab
key={entry.key}
label={entry.label}
n={counts[entry.key]}
isActive={status === entry.key}
onClick={() => onStatus(entry.key)}
/>
))}
</HStack>
{search}
</HStack>
);
}
/* ── Orders table ────────────────────────────────────────────────────────── */
function OrdersTable({
rows,
isLoading,
showBranch,
selection,
branchOf,
assigned,
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>;
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 (
<Card padding={0} elevation="low">
<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>COD</Th>
<Th>Amount</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 cod = row.collectionamt ?? 0;
const amount = orderValue(row);
const verdict = assignability(row, branchOf(row), assigned);
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>
{/* Only rendered when there is cash to collect — a COD column
of dashes is a column that costs width and says nothing. */}
<Td isStrong={cod > 0} isMuted={cod === 0}>
<Mono>{cod > 0 ? moneyExact(cod) : '—'}</Mono>
</Td>
<Td isStrong={amount > 0} isMuted={amount === 0}>
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
</Td>
<Td>
<Chip label={row.orderstatus} color={statusColor(ORDER_STATUS, row.orderstatus)} />
</Td>
<Td>
<ViewHint />
</Td>
</Row>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="orders" />
</Card>
);
}
/* ── 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 (
<Card padding={0} elevation="low">
<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>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>
<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>
<TablePager paged={paged} label="deliveries" />
</Card>
);
}
/* ── 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 (
<Card padding={0} elevation="low">
<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>
<TablePager paged={paged} label="bills" />
</Card>
);
}
/* ── 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,
});
}
function Search({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (next: string) => void;
placeholder: string;
}) {
return (
<div style={{ width: 240 }}>
<TextInput
label="Search"
isLabelHidden
size="sm"
value={value}
onChange={onChange}
placeholder={placeholder}
hasClear
/>
</div>
);
}
/** 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>
);
}
function StatusTab({
label,
n,
isActive,
onClick,
}: {
label: string;
n: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 30,
padding: '0 11px',
borderRadius: 12,
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
// Transparent when idle, not white. A row of white chips on a tinted
// page reads as six raised slabs; only the selected one should carry a
// fill, and the hairline is enough to keep the rest legible as chips.
background: isActive ? 'var(--color-brand)' : 'transparent',
color: isActive ? '#fff' : 'var(--color-ink-2)',
fontSize: 12.5,
fontWeight: 500,
cursor: 'pointer',
transition: 'background .2s, color .2s, border-color .2s',
}}
>
{label}
<span
style={{
fontVariantNumeric: 'tabular-nums',
fontWeight: 700,
opacity: isActive ? 0.85 : 0.55,
}}
>
{n}
</span>
</button>
);
}
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: isActive ? '#fff' : 'var(--color-ink-3)',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Loading({ what }: { what: string }) {
return (
<Card padding={0} elevation="low">
<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} elevation="low">
<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>
);
}