1538 lines
56 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|