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('orders'); const [status, setStatus] = useState('all'); const [preset, setPreset] = useState('month'); const [range, setRange] = useState(() => 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; }, [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 ( {/* 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. */} } isDisabled={!tenantid} onClick={() => setUploading(true)} /> { setPreset(nextPreset); setRange(nextRange); }} /> } tabs={ } badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined} isActive={tab === 'orders' || tab === 'deliveries'} onClick={() => setTab(tab === 'deliveries' ? 'deliveries' : 'orders')} /> {(tab === 'orders' || tab === 'deliveries') ? ( <> } 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')} /> } /> {/* 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' ? ( {STATUS_TABS.map((entry) => ( setStatus(entry.key)} /> ))} ) : null} {/* ── Orders ──────────────────────────────────────────────────────── */} {tab === 'orders' ? ( <>
} /> 0 ? 'warning' : 'neutral'} icon={} /> } /> 0 ? orderTotals.value / orderRows.length : 0)} note="in this view" tone="neutral" icon={} />
} /> 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={} />
} /> page.isLoading)} showBranch={selected === null} /> ) : null} {isUploading && tenantid ? ( branch.locationid)} onClose={() => setUploading(false)} /> ) : null} {detail ? ( setDetail(null)} /> ) : null}
); } /* ── Orders table ────────────────────────────────────────────────────────── */ function OrdersTable({ rows, isLoading, showBranch, onOpen, }: { rows: OrderRow[]; isLoading: boolean; showBranch: boolean; onOpen: (row: OrderRow) => void; }) { if (isLoading) return ; if (rows.length === 0) { return ( } title="No orders here" body="Nothing matches this date range, status or search." /> ); } return (
{showBranch ? : null} {/* 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 ( onOpen(row)}> {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
{index + 1} {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, }: { rows: DeliveryRow[]; isLoading: boolean; showBranch: boolean; onOpen: (row: DeliveryRow) => void; }) { 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} {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}`} {stamp(row.assigntime || 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, }: { rows: { bill: PosSale; branch: string }[]; isLoading: boolean; showBranch: boolean; }) { 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. */} {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 | 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 (
); } /** 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 }: { children: React.ReactNode; onClick: () => void }) { return ( { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick(); } }} style={{ cursor: 'pointer' }} > {children} ); } function Th({ children }: { children?: React.ReactNode; }) { return ( {children} ); } function Td({ children, isMuted, isStrong, }: { children: React.ReactNode; isMuted?: boolean; isStrong?: boolean; }) { return ( {children} ); } /** * 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 ( ); }