1262 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|