Files
daily_console_web/src/features/store-admin/pages/SalesPage.tsx
2026-08-28 11:03:56 +05:30

1262 lines
41 KiB
TypeScript

import { useMemo, useState } from 'react';
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, Upload, UserX } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
useDeliveries,
useOrders,
usePosBillsByBranch,
usePosSalesByBranch,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, moneyExact } from '../format';
import {
DELIVERY_STATUS,
ORDER_STATUS,
STATUS_TABS,
matchesStatus,
orderQuantity,
orderValue,
statusColor,
billedAtMs,
syncLagMs,
type StatusKey,
} from '../orderStatus';
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
import { shortAge } from '../posStatus';
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, current, branches, tenantid, isPinned } = useBranchScope();
const [isUploading, setUploading] = useState(false);
const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('all');
const [preset, setPreset] = useState<RangePreset>('month');
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const [keyword, setKeyword] = useState('');
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,
...range,
pagesize: 200,
}
: undefined;
const orders = useOrders(query);
const deliveries = useDeliveries(query);
const billPages = usePosBillsByBranch(branchIds, { ...range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, 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]);
/** Counts for the tab strip, from the unfiltered set. */
const tabCounts = useMemo(() => {
const source = tab === 'deliveries' ? allDeliveries : allOrders;
return Object.fromEntries(
STATUS_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]);
return (
<VStack gap={3}>
<PageHeader
title="Sales"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
isLive
actions={
<HStack gap={0.5} align="center" wrap="wrap">
{/* Counter sales that never touched a till. Sits beside the date
range rather than under the Counter tab because it is how the
figures on every tab get there for a shop with no POS. */}
<BarAction
label="Upload counter sales"
icon={<Upload size={14} />}
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</HStack>
}
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 === 'orders' || tab === 'deliveries'}
onClick={() => setTab(tab === 'deliveries' ? 'deliveries' : 'orders')}
/>
{(tab === 'orders' || tab === 'deliveries') ? (
<>
<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>
}
/>
{/* The status strip. Only orders and deliveries have a lifecycle — a
counter bill is rung and done, so it has nothing to filter by. */}
{tab !== 'counter' ? (
<HStack gap={0.5} wrap="wrap">
{STATUS_TABS.map((entry) => (
<StatusTab
key={entry.key}
label={entry.label}
n={tabCounts[entry.key]}
isActive={status === entry.key}
onClick={() => setStatus(entry.key)}
/>
))}
</HStack>
) : null}
{/* ── 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}>
<SectionHeader
title="Orders"
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
/>
<OrdersTable
rows={orderRows}
isLoading={orders.isLoading}
showBranch={selected === null}
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}>
<SectionHeader
title="Deliveries"
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
/>
<DeliveriesTable
rows={deliveryRows}
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}>
<SectionHeader
title="Counter bills"
action={
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
}
/>
<BillsTable
rows={billRows}
isLoading={billPages.some((page) => page.isLoading)}
showBranch={selected === null}
/>
</VStack>
</>
) : null}
{isUploading && tenantid ? (
<CounterSalesDrawer
tenantid={tenantid}
{...(isPinned && current ? { locationid: current.locationid } : {})}
branchIds={branches.map((branch) => branch.locationid)}
onClose={() => setUploading(false)}
/>
) : null}
{detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null}
</VStack>
);
}
/* ── Orders table ────────────────────────────────────────────────────────── */
function OrdersTable({
rows,
isLoading,
showBranch,
onOpen,
}: {
rows: OrderRow[];
isLoading: boolean;
showBranch: boolean;
onOpen: (row: OrderRow) => void;
}) {
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>
<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. */}
{rows.map((row, index) => {
const cod = row.collectionamt ?? 0;
const amount = orderValue(row);
return (
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
<Td isMuted>
<Mono>{index + 1}</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>
</Card>
);
}
/* ── Deliveries table ────────────────────────────────────────────────────── */
function DeliveriesTable({
rows,
isLoading,
showBranch,
onOpen,
}: {
rows: DeliveryRow[];
isLoading: boolean;
showBranch: boolean;
onOpen: (row: DeliveryRow) => void;
}) {
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>
{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>
<Secondary>{stamp(row.assigntime || 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>
</Card>
);
}
/* ── Counter bills ───────────────────────────────────────────────────────── */
function BillsTable({
rows,
isLoading,
showBranch,
}: {
rows: { bill: PosSale; branch: string }[];
isLoading: boolean;
showBranch: boolean;
}) {
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. */}
{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>
</Card>
);
}
/* ── Primitives ──────────────────────────────────────────────────────────── */
/** "21 Aug, 14:32". Falls back to the raw string rather than to "Invalid Date". */
function stamp(value: string | undefined): string {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return 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 }: { children: React.ReactNode; onClick: () => void }) {
return (
<tr
onClick={onClick}
tabIndex={0}
role="button"
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
}}
style={{ cursor: 'pointer' }}
>
{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>
);
}
/**
* A header action drawn like a tab, so the two rows read as one bar. Same shape
* as Inventory's — copied rather than shared because the two pages own their
* chrome, and a shared one would have to take a theme prop to stay identical.
*/
function BarAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
const [isHovered, setHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}