import { useState, useMemo } from 'react'; import { useDateScope } from '@/components/shell/DateScope'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react'; import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; import { useDeliveries, useOrders, usePosBillsByBranch, usePosSalesByBranch, } from '@/queries/hooks'; import { useBranchScope } from '../BranchScope'; import { branchLabel, count, money, moneyExact } from '../format'; import { DELIVERY_STATUS, ORDER_STATUS, DELIVERY_STATUS_TABS, ORDER_STATUS_TABS, matchesStatus, orderQuantity, orderValue, statusColor, billedAtMs, stampAfterMs, syncLagMs, type StatusKey, } from '../orderStatus'; import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer'; import { shortAge } from '../posStatus'; import { AssignBar } from '../AssignBar'; import { assignability, assignedFrom } from '../assignDelivery'; import { useSelection } from '@/components/useSelection'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import './deliveries.css'; type Tab = 'orders' | 'deliveries' | 'counter'; /** * Sales — app orders, delivery jobs and counter bills. * * Every column here comes from the old console rather than from what looked * reasonable, because the old columns encode things the data does not announce. * Three examples that a fresh design gets wrong: * * - **Amount is a fallback chain.** `ordervalue || orderamount || deliveryamt`. * Which field carries the money depends on the endpoint the row came * through, so reading one of them shows blanks on a third of the rows. * - **Deliveries are their own read.** Not a filter over orders. The delivery * row has a rider name, a planned distance AND an actual one, a rider charge * AND a job value, and its own status ladder. * - **COD only appears when there is cash to collect.** `collectionamt` on * every row would be a column of dashes. */ export function SalesPage() { const { scoped, selected, branches, tenantid } = useBranchScope(); const [tab, setTab] = useState('orders'); const [status, setStatus] = useState('all'); const dates = useDateScope(); const [keyword, setKeyword] = useState(''); /* What the tables are currently showing. Any change to it sends every pager back to page 1 — landing on page 4 of a search you just typed reads as an empty result. */ const queryKey = `${tab}|${status}|${keyword}|${dates.range.fromdate}|${dates.range.todate}|${selected ?? 'all'}`; const [detail, setDetail] = useState<{ row: OrderRow | DeliveryRow; kind: RowKind } | null>( null, ); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); // Status filtering happens client-side, not in the query. The tabs need a // count each, and asking the server per tab would be six round trips to // render one row of numbers. const query = tenantid ? { tenantid, locationid: selected ?? undefined, keyword: keyword.trim() || undefined, ...dates.range, pagesize: 200, } : undefined; const orders = useOrders(query); const deliveries = useDeliveries(query); const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 }); const posSummary = usePosSalesByBranch(branchIds, dates.range); const allOrders = orders.data ?? []; const allDeliveries = deliveries.data ?? []; const orderRows = useMemo( () => allOrders.filter((row) => matchesStatus(status, row.orderstatus)), [allOrders, status], ); const deliveryRows = useMemo( () => allDeliveries.filter((row) => matchesStatus(status, row.orderstatus)), [allDeliveries, status], ); const billRows = useMemo(() => { const out: { bill: PosSale; branch: string }[] = []; billPages.forEach((page, index) => { const branchId = branchIds[index]; const branch = branches.find((entry) => entry.locationid === branchId); for (const bill of page.data?.bills ?? []) { out.push({ bill, branch: branch?.locationname ?? `Branch ${branchId}` }); } }); const term = keyword.trim().toLowerCase(); return out .filter(({ bill }) => term === '' ? true : `${bill.invoicenumber} ${bill.terminalid} ${bill.cashiername} ${bill.customername}` .toLowerCase() .includes(term), ) /* Sorted on the recovered instant, not the raw stamp. A string compare put the one terminal that sends correct UTC five and a half hours adrift of the rest, so newest-first was newest-first only within a terminal. */ .sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0)); }, [billPages, branchIds, branches, keyword]); /** * The tab strip for whichever sub-tab is open. * * Orders and deliveries move through different ladders — an order is never * "arrived", a delivery is never "created" — so each gets its own tabs rather * than a shared set with half of it stuck at zero. */ const statusTabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; /** Counts for the tab strip, from the unfiltered set. */ const tabCounts = useMemo(() => { const source = tab === 'deliveries' ? allDeliveries : allOrders; const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; return Object.fromEntries( tabs.map((entry) => [ entry.key, source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length, ]), ) as Record; }, [tab, allOrders, allDeliveries]); const counterTotals = useMemo( () => posSummary.reduce( (acc, page) => ({ bills: acc.bills + (page.data?.billcount ?? 0), gross: acc.gross + (page.data?.grosssales ?? 0), tax: acc.tax + (page.data?.taxcollected ?? 0), }), { bills: 0, gross: 0, tax: 0 }, ), [posSummary], ); const orderTotals = useMemo(() => { const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0); const cod = orderRows.reduce((sum, row) => sum + (row.collectionamt ?? 0), 0); const qty = orderRows.reduce((sum, row) => sum + orderQuantity(row), 0); return { value, cod, qty }; }, [orderRows]); const deliveryTotals = useMemo(() => { const value = deliveryRows.reduce((sum, row) => sum + (row.deliveryamt ?? 0), 0); const charges = deliveryRows.reduce((sum, row) => sum + (row.deliverycharges ?? 0), 0); const unassigned = deliveryRows.filter((row) => !row.ridername).length; return { value, charges, unassigned }; }, [deliveryRows]); /* ── Assigning riders, from the orders list ───────────────────────────── */ /** * Which orders the deliveries list already accounts for. * * The order row's own `deliveryid` is the direct answer, and it is only * populated for deliveries created since the backend started writing it — * nothing wrote `orders.deliveryid` before, and the orders query aliased the * column away. Every delivery that predates that fix sits against an order * still reading 0, so without this the list would offer to assign the whole * of production's delivery history a second time. */ const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]); const branchOf = useMemo( () => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid), [branches], ); /** * The orders a rider can be put on, out of the ones currently listed. * * The selection is built from these rather than from every visible row, so * "select all" only ever ticks what can actually be sent. Handing it every * row instead ticked the disabled boxes too: they went grey-and-checked and * the bar counted more than it would assign, which is how a person stops * trusting the ticks. */ const selectable = useMemo( () => orderRows .filter((row) => assignability(row, branchOf(row), assigned).canAssign) .map((row) => row.orderheaderid), [orderRows, branchOf, assigned], ); const picked = useSelection(selectable); const pickedOrders = useMemo( () => orderRows.filter((row) => picked.has(row.orderheaderid)), [orderRows, picked], ); return ( } badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined} isActive={tab !== 'counter'} onClick={() => setTab(tab === 'counter' ? 'orders' : tab)} /> {tab !== 'counter' ? ( <> } badge={allOrders.length || undefined} isActive={tab === 'orders'} onClick={() => setTab('orders')} /> } badge={allDeliveries.length || undefined} isActive={tab === 'deliveries'} onClick={() => setTab('deliveries')} /> ) : null} } badge={billRows.length || undefined} isActive={tab === 'counter'} onClick={() => setTab('counter')} /> } /> {/* ── Orders ──────────────────────────────────────────────────────── */} {tab === 'orders' ? ( <>
} /> 0 ? 'warning' : 'neutral'} icon={} /> } /> 0 ? orderTotals.value / orderRows.length : 0)} note="in this view" tone="neutral" icon={} />
} /> {/* 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 ? ( ) : null} setDetail({ row, kind: 'order' })} /> {orderRows.length > 0 ? ( 0 ? [{ label: 'COD', value: money(orderTotals.cod), tone: 'warning' as const }] : []), { label: 'Value', value: money(orderTotals.value), tone: 'accent' as const }, ]} /> ) : null} ) : null} {/* ── Deliveries ──────────────────────────────────────────────────── */} {tab === 'deliveries' ? ( <>
} /> 0 ? 'warning' : 'success'} icon={} /> } /> } />
} /> setDetail({ row, kind: 'delivery' })} /> {deliveryRows.length > 0 ? ( ) : null} ) : null} {/* ── Counter sales ───────────────────────────────────────────────── */} {tab === 'counter' ? ( <>
} /> 0 ? counterTotals.gross / counterTotals.bills : 0)} note="per transaction" tone="neutral" icon={} /> } /> billRows.length ? `of ${count(counterTotals.bills)} — newest page` : 'all of them' } tone="neutral" icon={} />
{/* No status strip here: a counter bill is rung and done, so it has no lifecycle to filter by. The search keeps the row. */} page.isLoading)} showBranch={selected === null} /> ) : 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 ? ( setDetail(null)} /> ) : null}
); } /** * The open row, as the list currently has it. * * Keyed on `deliveryid` for a job and `orderheaderid` for an order — both are * stable primary keys, unlike `orderid`, which is not unique in this tenant's * data. Returns the captured row when the list has not caught up yet, so the * drawer never blanks mid-refetch. */ function freshRow( detail: { row: OrderRow | DeliveryRow; kind: RowKind }, orders: readonly OrderRow[], deliveries: readonly DeliveryRow[], ): OrderRow | DeliveryRow { if (detail.kind === 'delivery') { const id = (detail.row as DeliveryRow).deliveryid; return deliveries.find((row) => row.deliveryid === id) ?? detail.row; } const id = detail.row.orderheaderid; return orders.find((row) => row.orderheaderid === id) ?? detail.row; } /* ── The assignment queue ────────────────────────────────────────────────── */ /** * The status filters and the search, on one line above the table. * * This row replaces the "Orders" / "Deliveries" headings that used to sit here. * They named the table the tab had already named — two words of chrome between * the numbers and the rows — while the filters sat above the KPI cards, so the * control and the list it narrowed were separated by four cards. * * Below the cards is where the filters belong: the cards summarise everything * in view, the filters decide what is in view, and the table shows it. Reading * downward now follows that order. */ function FilterRow({ tabs, status, counts, onStatus, search, }: { /** Which ladder to show — orders and deliveries do not share one. */ tabs: readonly { readonly key: StatusKey; readonly label: string }[]; status: StatusKey; counts: Record; onStatus: (key: StatusKey) => void; search: React.ReactNode; }) { return ( {tabs.map((entry) => ( onStatus(entry.key)} /> ))} {search} ); } /* ── Orders table ────────────────────────────────────────────────────────── */ function OrdersTable({ rows, isLoading, showBranch, selection, branchOf, assigned, onOpen, queryKey, }: { rows: OrderRow[]; isLoading: boolean; /** Identifies the current filter set; changing it returns the pager to page 1. */ queryKey: string; showBranch: boolean; selection: ReturnType; branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; onOpen: (row: OrderRow) => void; }) { // Above the early returns — a hook cannot be called conditionally. const paged = usePaged(rows, { resetKey: queryKey }); if (isLoading) return ; if (rows.length === 0) { return ( } title="No orders here" body="Nothing matches this date range, status or search." /> ); } return (
{/* The tick column, first — the old console's position, and the one place a person looks for it. */} {showBranch ? : null} {/* Keyed on orderheaderid, never orderid — `orderid` is not unique in this tenant's data, a defect the old console hit first. */} {paged.rows.map((row, index) => { const cod = row.collectionamt ?? 0; const amount = orderValue(row); const verdict = assignability(row, branchOf(row), assigned); const isPicked = selection.has(row.orderheaderid); return ( 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. */} {showBranch ? ( ) : null} {/* Only rendered when there is cash to collect — a COD column of dashes is a column that costs width and says nothing. */} ); })}
# OrderBranchPickup Drop Qty COD Amount Status
event.stopPropagation()} style={{ borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)', verticalAlign: 'top', paddingTop: 11, }} > selection.toggle(row.orderheaderid)} /> {paged.from + index} {row.orderid || `#${row.orderheaderid}`} {stamp(row.orderdate)} {row.applocation || branchLabel(row.locationname)} {row.applocation ? ( {branchLabel(row.locationname)} ) : null} {row.pickupcustomer || branchLabel(row.locationname)} {row.pickupsuburb || row.pickupaddress} {row.deliverycustomer || '—'} {row.deliverysuburb || row.deliveryaddress} {orderQuantity(row) || '—'} 0} isMuted={cod === 0}> {cod > 0 ? moneyExact(cod) : '—'} 0} isMuted={amount === 0}> {amount > 0 ? moneyExact(amount) : '—'}
); } /* ── 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 ; if (rows.length === 0) { return ( } 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 (
{showBranch ? : null} {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 ( onOpen(row)}> {showBranch ? ( ) : null} {/* 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. */} ); })}
OrderBranchPickup Drop Rider KM Charge / value Status Notes
{row.orderid || `DLV-${row.deliveryid}`} {/* Measured against the order own time: deliverydate arrives 5:30 early on every row - see stampAfterMs. */} {stamp(stampAfterMs(row.assigntime || row.deliverydate, row.deliverydate))} {branchLabel(row.locationname)} {pickupName || pickupAddr || '—'} {pickupName ? pickupAddr : ''} {dropName || dropAddr || '—'} {dropName ? dropAddr : ''} {row.ridername ? ( {row.ridername} ) : ( Unassigned )} {planned ? planned.toFixed(1) : '—'} {actual > 0 ? {actual.toFixed(1)} : null} {charge > 0 ? {moneyExact(charge)} : null} {value > 0 ? {moneyExact(value)} : null} {charge === 0 && value === 0 ? ( — ) : null} {notes || '—'}
); } /* ── 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 ; if (rows.length === 0) { return ( } 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 (
{showBranch ? : null} {/* 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 ( {showBranch ? ( ) : null} ); })}
Invoice TimeBranchTill Cashier Customer Paid by Items Tax Total
{bill.invoicenumber || `#${bill.posorderid}`} {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 ? ( {' '} · synced {shortAge(lag)} late ) : null} {clockOf(billedAt ?? bill.billedat)} {branchLabel(branch)} {bill.terminalid || '—'} {bill.cashiername || '—'} {bill.customername || bill.customermobile || 'Walk-in'} {count(bill.itemcount)} {moneyExact(bill.taxamount)} {moneyExact(bill.total)}
); } /* ── Primitives ──────────────────────────────────────────────────────────── */ /** "21 Aug, 14:32". Falls back to the raw string rather than to "Invalid Date". */ function stamp(value: string | number | null | undefined): string { if (value === null || value === undefined || value === '') return '—'; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return String(value); return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`; } /** * Takes an epoch too, so a bill can pass the instant `billedAtMs` recovered * rather than the stamp it arrived with. Formatting the raw `billedat` shifted * an already-wrong time by another +05:30 and put a 17:17 sale on the row at * 22:47 — two bugs compounding into a plausible-looking number. */ function clockOf(value: string | number | undefined): string { if (value === undefined || value === '') return '—'; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return String(value); return parsed.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false, }); } function Search({ value, onChange, placeholder, }: { value: string; onChange: (next: string) => void; placeholder: string; }) { return (
); } /** A status chip in the lifecycle's own colour. */ function Chip({ label, color }: { label: string | undefined; color: string }) { return ( {label || '—'} ); } /** A small outlined number — used in pairs for planned/actual. */ function Pill({ color, children }: { color: string; children: React.ReactNode }) { return ( {children} ); } 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 ( Totals · {label} {chips.map((chip) => ( {chip.label} {chip.value} ))} ); } function StatusTab({ label, n, isActive, onClick, }: { label: string; n: number; isActive: boolean; onClick: () => void; }) { return ( ); } function TabButton({ label, icon, isActive, onClick, badge, }: { label: string; icon: React.ReactNode; isActive: boolean; onClick: () => void; badge?: number; }) { return ( ); } function Loading({ what }: { what: string }) { return ( Reading {what}… ); } function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { return ( {icon} {title} {body} ); } const Mono = ({ children }: { children: React.ReactNode }) => ( {children} ); const Primary = ({ children, w = 160 }: { children: React.ReactNode; w?: number }) => ( {children} ); const Secondary = ({ children, w = 160 }: { children: React.ReactNode; w?: number }) => children ? ( {children} ) : null; const ViewHint = () => ( View → ); 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 ( { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick(); } }} style={{ cursor: 'pointer', ...(isPicked ? { background: 'var(--color-brand-tint)' } : {}), }} > {children} ); } function Th({ children }: { children?: React.ReactNode; }) { return ( {children} ); } function Td({ children, isMuted, isStrong, }: { children: React.ReactNode; isMuted?: boolean; isStrong?: boolean; }) { return ( {children} ); }