From 14e4529613f83d1800674f7ee4437d3b72d96c1a Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 6 Aug 2026 18:04:51 +0530 Subject: [PATCH] pos integration --- src/App.tsx | 7 + src/components/PosConsoleView.tsx | 1668 +++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 4 +- src/components/UserStorePage.tsx | 69 +- src/services/posApi.ts | 644 +++++++++++ src/services/posQueries.ts | 176 +++ src/types.ts | 2 +- 7 files changed, 2566 insertions(+), 4 deletions(-) create mode 100644 src/components/PosConsoleView.tsx create mode 100644 src/services/posApi.ts create mode 100644 src/services/posQueries.ts diff --git a/src/App.tsx b/src/App.tsx index 9f9df98..5716a1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,6 +52,7 @@ import InventoryView from './components/InventoryView'; import SettingsView from './components/SettingsView'; import StoreDetailView from './components/StoreDetailView'; import DispatchHubView from './components/DispatchHubView'; +import PosConsoleView from './components/PosConsoleView'; import LoginView from './components/LoginView'; import UserStorePage from './components/UserStorePage'; import SuperAdminPage from './components/SuperAdminPage'; @@ -671,6 +672,12 @@ export default function App() { } /> + + {/* Tenant-wide POS: no locationid, so the view fans out over every + outlet under the tenant. */} + + } /> diff --git a/src/components/PosConsoleView.tsx b/src/components/PosConsoleView.tsx new file mode 100644 index 0000000..80be0fd --- /dev/null +++ b/src/components/PosConsoleView.tsx @@ -0,0 +1,1668 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * POS console — counter sales and till health. + * + * One component serves both workspaces: + * + * • **admin** (no `locationid`) — every outlet under the tenant, fanned out + * over `useFiestaTenantLocations`, with a store leaderboard and a store + * picker on the bill list. + * • **store** (`locationid` set) — that one outlet, no picker, no + * cross-store comparison. + * + * The split is a prop rather than two components because every panel is the + * same read with a different scope; duplicating them would guarantee the two + * drift. + * + * Notes that are load-bearing rather than stylistic: + * – `grosssales` from the API is **net** of discount and round-off, so it is + * labelled "Net collected" here and never "Gross". + * – Average bill is always recomputed as Σtotal / Σbills. + * – A negative sync lag is a legacy timestamp, not a fast upload. + * – Terminal health has four states; see `parseTerminal`. + */ + +import React, { useMemo, useState } from 'react'; +import { + AlertTriangle, + Barcode, + Battery, + Clock, + CreditCard, + HardDrive, + Monitor, + Package, + Printer, + Receipt, + RefreshCcw, + Search, + Store, + TrendingUp, + Users, + Wifi, +} from 'lucide-react'; +import { useFiestaTenantLocations, FIESTA_TENANT_ID } from '../services/fiestaQueries'; +import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; +import { + averageBill, + billCustomer, + billPayments, + billPromos, + billTaxBreakdown, + billTime, + daysAgoISO, + humanDuration, + inr, + inrCompact, + syncLag, + terminalLabel, + todayISO, + TERMINAL_STATE_COLOR, + TERMINAL_STATE_LABEL, + type PosBill, + type PosCatalogue, + type PosSalesSummary, + type PosTerminal, +} from '../services/posApi'; +import { + usePosCatalogue, + usePosFleetHealth, + usePosSaleDetail, + usePosSales, + usePosSalesSummary, +} from '../services/posQueries'; +import { + BORDER, + BRAND, + Card, + DIVIDER, + FilterBar, + GradientHeader, + KpiStrip, + LiveStatus, + MetricPill, + Pill, + SlideDrawer, + StatusChip, + SURFACE_ALT, + TH_STYLE, + TEXT, + TEXT_2, + TEXT_3, + edge, + tint, +} from './consoleUi'; + +interface PosConsoleViewProps { + tenantId?: number; + /** + * Set for the store workspace; omitted for the tenant-wide admin view. + * + * This must be a real `tenantlocations.locationid`. It is the authorisation + * boundary on every POS read, so a caller that falls back to an + * `applocationid` (a city id) would query a scope that is either empty or, + * if the ids happen to collide, somebody else's outlet. + */ + locationid?: number; + storeName?: string; +} + +type Tab = 'overview' | 'terminals' | 'bills' | 'catalogue' | 'exceptions'; + +const TABS: Array<{ id: Tab; label: string; icon: React.ReactNode }> = [ + { id: 'overview', label: 'Overview', icon: }, + { id: 'terminals', label: 'Terminals', icon: }, + { id: 'bills', label: 'Bills', icon: }, + { id: 'catalogue', label: 'Catalogue', icon: }, + { id: 'exceptions', label: 'Attention', icon: }, +]; + +const RANGES: Array<{ id: string; label: string; from: () => string }> = [ + { id: 'today', label: 'Today', from: todayISO }, + { id: '7d', label: '7 days', from: () => daysAgoISO(6) }, + { id: '30d', label: '30 days', from: () => daysAgoISO(29) }, +]; + +const PAGE_SIZE = 50; + +export default function PosConsoleView({ + tenantId = FIESTA_TENANT_ID, + locationid, + storeName, +}: PosConsoleViewProps) { + const isStoreScope = Boolean(locationid && locationid > 0); + + // The outlet roster is the page's ONLY non-POS read, and it is unavoidable: + // every POS endpoint takes `locationid` as an input and none of them + // enumerates a tenant's outlets, so a tenant-wide view has to learn the list + // from somewhere else before it can fan out. + // + // In store scope it is not needed at all — the caller already knows which + // outlet this is — so the query is switched off there (the shared hook is + // `enabled: Boolean(tenantid)`), leaving the store workspace served entirely + // by the POS API. + const locationsQ = useFiestaTenantLocations(isStoreScope ? 0 : tenantId); + + /** Every outlet in scope, with a display name. */ + const outlets = useMemo(() => { + if (isStoreScope) { + return [{ locationid: locationid as number, name: storeName || `Store ${locationid}` }]; + } + return (locationsQ.data ?? []) + .map((r: Row) => ({ + locationid: fnum(r.locationid), + name: fstr(r.locationname) || `Store ${fnum(r.locationid)}`, + })) + .filter((o) => o.locationid > 0) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [locationsQ.data, isStoreScope, locationid, storeName]); + + const scopeIds = useMemo(() => outlets.map((o) => o.locationid), [outlets]); + const outletName = useMemo(() => { + const m = new Map(); + outlets.forEach((o) => m.set(o.locationid, o.name)); + return m; + }, [outlets]); + + const [tab, setTab] = useState('overview'); + const [rangeId, setRangeId] = useState('today'); + const range = RANGES.find((r) => r.id === rangeId) ?? RANGES[0]; + const fromdate = range.from(); + const todate = todayISO(); + + const healthQ = usePosFleetHealth(scopeIds); + const summaryQ = usePosSalesSummary(scopeIds, fromdate, todate); + + const merged = summaryQ.data?.merged; + const perOutlet = summaryQ.data?.perOutlet ?? []; + + const allTerminals: Array = useMemo( + () => + (healthQ.data ?? []).flatMap((o) => + o.terminals.map((t) => ({ ...t, locationid: o.locationid })), + ), + [healthQ.data], + ); + + const tillsOnline = allTerminals.filter((t) => t.state === 'online').length; + const pendingTotal = allTerminals.reduce((s, t) => s + t.pendingBills, 0); + + // ── Bill list scope ──────────────────────────────────────────────────────── + // /sales takes exactly one locationid, so the tenant view needs a picker. + // It defaults to the outlet that actually has bills rather than the first + // alphabetically — with one live outlet on the platform, defaulting blind + // would open on an empty table every time. + const busiestOutlet = useMemo(() => { + const withBills = perOutlet.filter((p) => (p.summary?.billcount ?? 0) > 0); + if (withBills.length) { + return withBills.reduce((a, b) => + (b.summary?.billcount ?? 0) > (a.summary?.billcount ?? 0) ? b : a, + ).locationid; + } + return scopeIds[0] ?? 0; + }, [perOutlet, scopeIds]); + + const [pickedOutlet, setPickedOutlet] = useState(null); + const billOutlet = isStoreScope ? (locationid as number) : (pickedOutlet ?? busiestOutlet); + + const [terminalFilter, setTerminalFilter] = useState(''); + const [cashierFilter, setCashierFilter] = useState(''); + const [modeFilter, setModeFilter] = useState(''); + const [pageno, setPageno] = useState(0); + const [reference, setReference] = useState(''); + const [openRef, setOpenRef] = useState(''); + + const salesQ = usePosSales({ + locationid: billOutlet, + fromdate, + todate, + terminalid: terminalFilter, + cashiername: cashierFilter, + paymentmode: modeFilter, + pageno, + pagesize: PAGE_SIZE, + }); + + // Only fetched once its panel is opened — a catalogue pull is the whole shelf. + const catalogueQ = usePosCatalogue(billOutlet, tab === 'catalogue'); + + const detailQ = usePosSaleDetail(billOutlet, openRef); + const bills = salesQ.data?.bills ?? []; + const billTotal = salesQ.data?.total ?? 0; + + const isLoading = locationsQ.isLoading || summaryQ.isLoading || healthQ.isLoading; + const isError = locationsQ.isError || summaryQ.isError; + + const resetPaging = () => setPageno(0); + + // ── Exceptions ───────────────────────────────────────────────────────────── + const exceptions = useMemo(() => { + const stranded = allTerminals.filter((t) => t.pendingBills > 0); + const idle = allTerminals.filter((t) => t.state === 'online' && t.todayBills === 0); + const dark = allTerminals.filter( + (t) => t.state === 'offline_declared' || t.state === 'offline_vanished', + ); + const stale = allTerminals.filter((t) => t.state === 'stale'); + const drifting = allTerminals.filter( + (t) => t.clockDriftSeconds != null && Math.abs(t.clockDriftSeconds) > 120, + ); + const noTills = outlets.filter( + (o) => !(healthQ.data ?? []).some((h) => h.locationid === o.locationid && h.total > 0), + ); + const delayed = bills.filter((b) => syncLag(b).kind === 'delayed'); + const legacy = bills.filter((b) => syncLag(b).kind === 'legacy'); + const unassigned = bills.filter((b) => !b.terminalid?.trim()); + return { stranded, idle, dark, stale, drifting, noTills, delayed, legacy, unassigned }; + }, [allTerminals, outlets, healthQ.data, bills]); + + const attentionCount = + exceptions.stranded.length + exceptions.idle.length + exceptions.dark.length + exceptions.drifting.length; + + // ── Render ───────────────────────────────────────────────────────────────── + + // No padding of its own: both consoles already pad their route wrapper, and + // adding more here made the admin page sit inside two sets of gutters. + if (!isStoreScope && !locationsQ.isLoading && outlets.length === 0) { + return ( + } + title="No outlets found" + body="This tenant has no locations, so there is nothing for a till to be registered against." + /> + ); + } + + return ( +
+ + } + right={ + // Wraps rather than overflowing: GradientHeader marks this side + // shrink-0, so a fixed row would push the title off a narrow viewport. +
+ {RANGES.map((r) => ( + + { + setRangeId(r.id); + resetPaging(); + }} + > + {r.label} + + + ))} + +
+ } + /> + + , + badge: `${merged?.billcount ?? 0} bills`, + }, + { + label: 'Tax collected', + value: inrCompact(merged?.taxcollected ?? 0), + color: '#0ea5e9', + icon: , + badge: merged?.discountgiven ? `${inrCompact(merged.discountgiven)} disc.` : undefined, + }, + { + label: 'Average bill', + value: inrCompact(merged?.averagebill ?? 0), + color: '#10b981', + icon: , + badge: `${merged?.itemcount ?? 0} items`, + }, + { + label: 'Tills online', + value: `${tillsOnline}/${allTerminals.length}`, + color: pendingTotal > 0 ? '#f59e0b' : '#64748b', + icon: , + badge: pendingTotal > 0 ? `${pendingTotal} unsynced` : undefined, + onClick: () => setTab('terminals'), + }, + ]} + /> + + {pendingTotal > 0 && ( +
+ +
+ {pendingTotal} bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '} + {exceptions.stranded.length} till{exceptions.stranded.length === 1 ? '' : 's'} and not yet synced. + +
+
+ )} + +
+ +
+ {TABS.map((t) => ( + + 0 ? '#f59e0b' : BRAND} + onClick={() => setTab(t.id)} + count={t.id === 'exceptions' && attentionCount > 0 ? attentionCount : undefined} + > + {t.icon} + {t.label} + + + ))} +
+
+
+ +
+ {tab === 'overview' && ( + { + setPickedOutlet(id); + setTab('bills'); + resetPaging(); + }} + /> + )} + + {tab === 'terminals' && ( + + )} + + {tab === 'bills' && ( + { + setPickedOutlet(id); + resetPaging(); + }} + bills={bills} + total={billTotal} + pageno={pageno} + setPageno={setPageno} + loading={salesQ.isLoading} + fetching={salesQ.isFetching} + terminalFilter={terminalFilter} + setTerminalFilter={(v) => { + setTerminalFilter(v); + resetPaging(); + }} + cashierFilter={cashierFilter} + setCashierFilter={(v) => { + setCashierFilter(v); + resetPaging(); + }} + modeFilter={modeFilter} + setModeFilter={(v) => { + setModeFilter(v); + resetPaging(); + }} + reference={reference} + setReference={setReference} + onLookup={() => setOpenRef(reference.trim())} + onOpen={(b) => setOpenRef(b.terminalorderid || String(b.posorderid))} + terminalOptions={(merged?.byterminal ?? []).map((t) => t.terminalid)} + /> + )} + + {tab === 'catalogue' && ( + + )} + + {tab === 'exceptions' && ( + setOpenRef(b.terminalorderid || String(b.posorderid))} + /> + )} +
+ + setOpenRef('')} title="Bill"> + + +
+ ); +} + +// ── Overview ───────────────────────────────────────────────────────────────── + +function OverviewTab({ + merged, + perOutlet, + outletName, + isStoreScope, + loading, + onPickOutlet, +}: { + merged: PosSalesSummary | undefined; + perOutlet: Array<{ locationid: number; summary: PosSalesSummary | null }>; + outletName: Map; + isStoreScope: boolean; + loading: boolean; + onPickOutlet: (id: number) => void; +}) { + if (loading) return ; + if (!merged || merged.billcount === 0) { + return ( + } + title="No counter sales in this range" + body="Bills appear here as tills sync them. A till holds its own copy until we acknowledge, so nothing is lost while it is offline." + /> + ); + } + + const maxMode = Math.max(...merged.bypaymentmode.map((m) => m.amount), 1); + const maxDay = Math.max(...merged.byday.map((d) => d.amount), 1); + + const leaderboard = perOutlet + .filter((p) => (p.summary?.billcount ?? 0) > 0) + .map((p) => ({ + locationid: p.locationid, + name: outletName.get(p.locationid) || `Store ${p.locationid}`, + bills: p.summary?.billcount ?? 0, + net: p.summary?.grosssales ?? 0, + tax: p.summary?.taxcollected ?? 0, + // Recomputed, never the API's own averagebill folded across outlets. + avg: averageBill(p.summary), + })) + .sort((a, b) => b.net - a.net); + + return ( + <> +
+ + } title="Payment mix" hint="What the drawer is settled against" /> +
+ {merged.bypaymentmode.map((m) => ( +
+
+ + {m.paymentmode || 'Unrecorded'} + + + {inr(m.amount)} · {m.billcount} + +
+
+
+
+
+ ))} +
+ + + + } title="By day" hint="Business date, as the till filed it" /> +
+ {merged.byday.map((d) => ( +
+ + {inrCompact(d.amount)} + +
+ + {d.businessdate.slice(5)} + +
+ ))} +
+ +
+ + {!isStoreScope && leaderboard.length > 0 && ( + + {leaderboard.map((r) => ( + onPickOutlet(r.locationid)} + > + + {r.name} + + + {r.bills} + + + {inr(r.net)} + + + {inr(r.tax)} + + + {inr(r.avg)} + + + ))} + + )} + + {merged.byterminal.length > 0 && ( + + {merged.byterminal.map((t) => ( + + + {terminalLabel(t.terminalid)} + {!t.terminalid && ( + + (ingested before terminal ids were recorded) + + )} + + + {t.billcount} + + + {inr(t.amount)} + + + ))} + + )} + +

+ “Net collected” is the sum of bill totals — after discount and round-off. Gross before discount is{' '} + {inr((merged.grosssales ?? 0) + (merged.discountgiven ?? 0))}. +

+ + ); +} + +// ── Terminals ──────────────────────────────────────────────────────────────── + +function TerminalsTab({ + health, + outletName, + isStoreScope, + loading, +}: { + health: Array<{ locationid: number; total: number; online: number; terminals: PosTerminal[] }>; + outletName: Map; + isStoreScope: boolean; + loading: boolean; +}) { + if (loading) return ; + + const withTills = health.filter((h) => h.total > 0); + const without = health.filter((h) => h.total === 0); + + if (withTills.length === 0) { + return ( + } + title="No terminals registered" + body={ + isStoreScope + ? 'No till has ever reported from this store. A terminal appears here the first time it sends a heartbeat.' + : `None of these ${health.length} outlets has a till reporting. A terminal appears the first time it sends a heartbeat.` + } + /> + ); + } + + return ( + <> + {withTills.map((h) => ( +
+ {!isStoreScope && ( +
+ +

+ {outletName.get(h.locationid) || `Store ${h.locationid}`} +

+ + {h.online}/{h.total} online + +
+ )} +
+ {h.terminals.map((t) => ( + + + + ))} +
+
+ ))} + + {without.length > 0 && !isStoreScope && ( + + } title={`${without.length} outlet${without.length === 1 ? '' : 's'} with no till`} hint="Never registered a terminal" /> +
+ {without.map((h) => ( + + {outletName.get(h.locationid) || `Store ${h.locationid}`} + + ))} +
+
+ )} + + ); +} + +function TerminalCard({ t }: { t: PosTerminal }) { + const color = TERMINAL_STATE_COLOR[t.state]; + const drift = t.clockDriftSeconds != null && Math.abs(t.clockDriftSeconds) > 120; + + return ( + +
+
+
+ {t.terminalId || '—'} +
+ {t.storeName && ( +
+ {t.storeName} +
+ )} +
+ +
+ + {t.state === 'offline_vanished' ? ( +

+ {t.reason || 'No heartbeat inside the presence window.'} +

+ ) : ( + <> +
+ + 0} + /> +
+ +
+ {t.appVersion && } + {t.ageSeconds != null && ( + } /> + )} + {t.printerReachable != null && ( + } + color={t.printerReachable ? undefined : '#ef4444'} + /> + )} + {t.batteryLevel != null && ( + } + color={t.batteryLevel < 20 ? '#ef4444' : undefined} + /> + )} + {t.storageFreeMb != null && ( + } color={t.storageFreeMb < 200 ? '#f59e0b' : undefined} /> + )} + {t.drawerStatus && } +
+ + {drift && ( +

+ Clock is {humanDuration(t.clockDriftSeconds as number)}{' '} + {(t.clockDriftSeconds as number) > 0 ? 'ahead of' : 'behind'} the server — bills may be filed under the + wrong business date. +

+ )} + + )} +
+ ); +} + +// ── Bills ──────────────────────────────────────────────────────────────────── + +function BillsTab(props: { + isStoreScope: boolean; + outlets: Array<{ locationid: number; name: string }>; + perOutlet: Array<{ locationid: number; summary: PosSalesSummary | null }>; + billOutlet: number; + onPickOutlet: (id: number) => void; + bills: PosBill[]; + total: number; + pageno: number; + setPageno: (n: number) => void; + loading: boolean; + fetching: boolean; + terminalFilter: string; + setTerminalFilter: (v: string) => void; + cashierFilter: string; + setCashierFilter: (v: string) => void; + modeFilter: string; + setModeFilter: (v: string) => void; + reference: string; + setReference: (v: string) => void; + onLookup: () => void; + onOpen: (b: PosBill) => void; + terminalOptions: string[]; +}) { + const { + isStoreScope, outlets, perOutlet, billOutlet, onPickOutlet, bills, total, pageno, setPageno, + loading, fetching, terminalFilter, setTerminalFilter, cashierFilter, setCashierFilter, + modeFilter, setModeFilter, reference, setReference, onLookup, onOpen, terminalOptions, + } = props; + + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const billsFor = (id: number) => perOutlet.find((p) => p.locationid === id)?.summary?.billcount ?? 0; + + return ( + <> + +
+ {!isStoreScope && ( +
+ + Store + + {outlets.map((o) => ( + + onPickOutlet(o.locationid)} + count={billsFor(o.locationid) || undefined} + > + {o.name} + + + ))} +
+ )} + +
+
+ + setReference(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onLookup()} + placeholder="Invoice, order UUID or bill id…" + className="bg-transparent outline-none text-[12.5px] w-56" + style={{ color: TEXT }} + /> + +
+ + + + + +
+ + setCashierFilter(e.target.value)} + placeholder="Cashier (exact name)" + className="bg-transparent outline-none text-[12.5px] w-40" + style={{ color: TEXT }} + /> +
+ + + {total} bill{total === 1 ? '' : 's'} + {fetching && ' · updating…'} + +
+
+
+ + {loading ? ( + + ) : bills.length === 0 ? ( + } + title="No bills match" + body="Nothing was rung at this outlet in this range, or the filters exclude everything. Note the cashier filter is an exact, case-sensitive match." + /> + ) : ( + 1 ? ( +
+ + Page {pageno + 1} of {pages} + +
+ + +
+
+ ) : undefined + } + > + {bills.map((b) => { + const lag = syncLag(b); + return ( + onOpen(b)}> + +
+ {b.invoicenumber || `#${b.posorderid}`} +
+
+ {b.businessdate} + {lag.kind === 'delayed' && ( + · synced {humanDuration(lag.seconds)} late + )} + {lag.kind === 'legacy' && · legacy timestamp} +
+ + + {billTime(b.billedat)} + + + {terminalLabel(b.terminalid)} + + + {b.cashiername || '—'} + + + {billCustomer(b)} + + + + + + {b.itemcount} + + + {inr(b.taxamount)} + + + {inr(b.total)} + + + ); + })} +
+ )} + + ); +} + +// ── Bill detail ────────────────────────────────────────────────────────────── + +function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loading: boolean; reference: string }) { + if (loading) return ; + if (!bill) { + return ( + } + title="No bill found" + body={`Nothing at this outlet matches “${reference}”. A reference that belongs to another store answers the same way — the lookup is scoped to one outlet on purpose.`} + /> + ); + } + + const payments = billPayments(bill); + const taxes = billTaxBreakdown(bill); + const promos = billPromos(bill); + const lag = syncLag(bill); + const items = bill.items ?? []; + const gross = bill.subtotal || items.reduce((s, i) => s + i.linetotal, 0); + + return ( +
+
+
+ {bill.invoicenumber || `Bill #${bill.posorderid}`} +
+
+ {bill.businessdate} · {billTime(bill.billedat)} · {terminalLabel(bill.terminalid)} + {bill.cashiername && ` · ${bill.cashiername}`} +
+
+ +
+ + + + +
+ + {items.length > 0 ? ( +
+ } title={`${items.length} line${items.length === 1 ? '' : 's'}`} /> + {/* The drawer clips horizontally, so the line table scrolls inside + its own box rather than being cut off at the panel edge. */} +
+ + + + + + + + + + + + {items.map((it) => ( + + + + + + + + ))} + +
ItemQtyRateGSTTotal
+
{it.productname || `#${it.productid}`}
+ {it.barcode && ( +
{it.barcode}
+ )} +
+ {it.quantity} + {it.unitname ? ` ${it.unitname}` : ''} + {inr(it.unitprice)} + {/* gstrate arrives as a fraction (0.18), not a percentage. */} + {(it.gstrate * 100).toFixed(it.gstrate * 100 % 1 === 0 ? 0 : 2)}% +
{inr(it.taxamount)}
+
{inr(it.linetotal)}
+
+
+ ) : ( +

+ This bill has no stored line items. +

+ )} + +
+ + {bill.discount > 0 && } + + {bill.roundoff !== 0 && } +
+ +
+
+ + {payments.length > 0 && ( +
+ } title="Tender split" hint="`paymentmode` names only the largest" /> +
+ {payments.map((p, i) => ( +
+ + {p.method} + {p.reference && ( + {p.reference} + )} + + {inr(p.amount)} +
+ ))} +
+
+ )} + + {taxes.length > 0 && ( +
+ } title="GST by slab" /> +
+ {taxes.map((t) => ( +
+ {(t.rate * 100).toFixed(2)}% + {inr(t.amount)} +
+ ))} +
+
+ )} + + {promos.length > 0 && ( +
+ } title="Promotions applied" /> +
+ {promos.map((p, i) => ( +
+ {p.name || p.id || 'Promotion'} + {p.amount != null ? inr(p.amount) : '—'} +
+ ))} +
+
+ )} + + {(bill.pointsearned > 0 || bill.pointsredeemed > 0) && ( +
+ + +
+ )} + +
+ + + + + + +
+
+ ); +} + +// ── Catalogue ──────────────────────────────────────────────────────────────── + +/** + * What a till at this outlet can actually sell. + * + * This is the terminal's own view, pulled from the same endpoint the till uses, + * so it answers the question no other screen can: not "what is in the catalogue" + * but "what would this counter let a cashier ring up right now". An unpriced + * product comes down `is_active: false` precisely so a till cannot sell it at + * ₹0, and that is invisible everywhere else in the product. + */ +function CatalogueTab({ + data, + loading, + isStoreScope, + outlets, + billOutlet, + onPickOutlet, + outletLabel, +}: { + data: PosCatalogue | undefined; + loading: boolean; + isStoreScope: boolean; + outlets: Array<{ locationid: number; name: string }>; + billOutlet: number; + onPickOutlet: (id: number) => void; + outletLabel: string; +}) { + const [onlyBlocked, setOnlyBlocked] = useState(false); + + + + if (loading) { + return ( + <> + + + + ); + } + + const products = data?.products ?? []; + if (!data || products.length === 0) { + return ( + <> + + } + title="Nothing in this till's catalogue" + body="No product has been published to this outlet, so a terminal here has nothing to sell. Products appear once they are stocked and priced for the store." + /> + + ); + } + + const blocked = products.filter((p) => !p.is_active); + const unpriced = products.filter((p) => p.price <= 0); + const outOfStock = products.filter((p) => p.stock <= 0); + const noGst = products.filter((p) => !p.gst_rate); + const shown = onlyBlocked ? blocked : products; + + return ( + <> + + + + + + {blocked.length > 0 && ( +
+ setOnlyBlocked(!onlyBlocked)} count={blocked.length}> + Only unsellable + +
+ )} + + + {shown.map((p) => ( + + +
{p.name || `#${p.id}`}
+
+ {p.barcode || p.id} + {p.brand ? ` · ${p.brand}` : ''} + {p.hsn_code ? ` · HSN ${p.hsn_code}` : ''} +
+ + {p.category || '—'} + {p.unit || '—'} + 0 ? TEXT : '#f59e0b' }}> + {p.price > 0 ? inr(p.price) : 'Unpriced'} + + 0 ? TEXT_2 : '#f59e0b' }}>{p.stock} + + {/* gst_rate is a fraction (0.05), matching how the till holds it. */} + {(p.gst_rate * 100).toFixed((p.gst_rate * 100) % 1 === 0 ? 0 : 2)}% + + + + + + ))} +
+ + ); +} + +// ── Exceptions ─────────────────────────────────────────────────────────────── + +function ExceptionsTab({ + exceptions, + outletName, + loading, + onOpenBill, +}: { + exceptions: { + stranded: Array; + idle: Array; + dark: Array; + stale: Array; + drifting: Array; + noTills: Array<{ locationid: number; name: string }>; + delayed: PosBill[]; + legacy: PosBill[]; + unassigned: PosBill[]; + }; + outletName: Map; + loading: boolean; + onOpenBill: (b: PosBill) => void; +}) { + if (loading) return ; + + const where = (t: { locationid: number }) => outletName.get(t.locationid) || `Store ${t.locationid}`; + const nothing = + exceptions.stranded.length === 0 && + exceptions.idle.length === 0 && + exceptions.dark.length === 0 && + exceptions.stale.length === 0 && + exceptions.drifting.length === 0 && + exceptions.delayed.length === 0; + + if (nothing) { + return ( + } + title="Nothing needs attention" + body="Every till is reporting, nothing is queued unsynced, and no bill took an unusual time to arrive." + /> + ); + } + + return ( + <> + ({ + key: `${t.locationid}-${t.terminalId}`, + primary: t.terminalId, + secondary: where(t), + value: `${t.pendingBills} bill${t.pendingBills === 1 ? '' : 's'}`, + note: t.oldestPendingAt ? `oldest ${billTime(t.oldestPendingAt)}` : undefined, + }))} + /> + + ({ + key: `${t.locationid}-${t.terminalId}`, + primary: t.terminalId, + secondary: where(t), + value: TERMINAL_STATE_LABEL[t.state], + note: t.reason || undefined, + }))} + /> + + ({ + key: `${t.locationid}-${t.terminalId}`, + primary: t.terminalId, + secondary: where(t), + value: '0 bills today', + note: t.lastBillAt ? `last bill ${billTime(t.lastBillAt)}` : 'no bill recorded', + }))} + /> + + ({ + key: `${t.locationid}-${t.terminalId}`, + primary: t.terminalId, + secondary: where(t), + value: t.ageSeconds != null ? `${humanDuration(t.ageSeconds)} ago` : 'unknown', + }))} + /> + + ({ + key: `${t.locationid}-${t.terminalId}`, + primary: t.terminalId, + secondary: where(t), + value: humanDuration(t.clockDriftSeconds as number), + note: (t.clockDriftSeconds as number) > 0 ? 'ahead of server' : 'behind server', + }))} + /> + + {exceptions.delayed.length > 0 && ( + + } + title={`${exceptions.delayed.length} bill${exceptions.delayed.length === 1 ? '' : 's'} arrived late`} + hint="Over 15 minutes between being rung and reaching us — an outage backlog" + /> +
+ {exceptions.delayed.slice(0, 12).map((b) => { + const lag = syncLag(b); + return ( + + ); + })} +
+
+ )} + + {(exceptions.legacy.length > 0 || exceptions.unassigned.length > 0) && ( + + } title="Known legacy data" hint="Not alerts — recorded so the panels above read honestly" /> +
    + {exceptions.legacy.length > 0 && ( +
  • + {exceptions.legacy.length} bill + {exceptions.legacy.length === 1 ? '' : 's'} on this page carry a pre-fix timestamp (local time stamped as + UTC), so their sync lag is not measurable. Business dates are unaffected. +
  • + )} + {exceptions.unassigned.length > 0 && ( +
  • + {exceptions.unassigned.length} bill + {exceptions.unassigned.length === 1 ? '' : 's'} have no terminal id — ingested before the fallback landed. + They group under “Unassigned”. +
  • + )} +
+
+ )} + + {exceptions.noTills.length > 0 && ( + + } title={`${exceptions.noTills.length} outlet${exceptions.noTills.length === 1 ? '' : 's'} with no till`} /> +
+ {exceptions.noTills.map((o) => ( + + {o.name} + + ))} +
+
+ )} + + ); +} + +function ExceptionGroup({ + tone, + title, + hint, + rows, +}: { + tone: string; + title: string; + hint?: string; + rows: Array<{ key: string; primary: string; secondary: string; value: string; note?: string }>; +}) { + if (rows.length === 0) return null; + return ( + + } title={`${title} (${rows.length})`} hint={hint} color={tone} /> +
+ {rows.map((r) => ( +
+
+
{r.primary || '—'}
+
{r.secondary}
+
+
+
{r.value}
+ {r.note &&
{r.note}
} +
+
+ ))} +
+
+ ); +} + +// ── Small shared pieces ────────────────────────────────────────────────────── + +/** + * Table shell with per-column alignment. + * + * The shared `TableShell` right-aligns every header from index 2 onward, which + * is right for its own screens but disagrees with these tables: the leaderboard + * has a numeric column at index 1, and the bill list has three text columns + * after it. Declaring the alignment per column keeps each header sitting over + * its own cells instead of drifting off to one side. + */ +interface PosColumn { + label: string; + align?: 'left' | 'right'; +} + +function PosTable({ + columns, + minWidth, + footer, + children, +}: { + columns: PosColumn[]; + minWidth?: number; + footer?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ + + + {columns.map((c, i) => ( + + ))} + + + + {children} + +
+ {c.label} +
+
+ {footer} +
+ ); +} + +function PanelTitle({ icon, title, hint, color = TEXT_2 }: { icon: React.ReactNode; title: string; hint?: string; color?: string }) { + return ( +
+ {icon} +
+

{title}

+ {hint &&

{hint}

} +
+
+ ); +} + +function Stat({ label, value, sub, alert }: { label: string; value: string; sub?: string; alert?: boolean }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function Chip({ label, icon, color = TEXT_2 }: { label: string; icon?: React.ReactNode; color?: string }) { + return ( + + {icon} + {label} + + ); +} + +function Line({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** + * Icon, heading and body stacked on one centred axis inside a fixed measure. + * + * The whole block is centred as a unit rather than each element being centred + * independently — otherwise a long body wraps to the card's full width while + * the short heading above it sits on a different visual centre, and the two + * read as misaligned even though both are technically centred. + */ +function EmptyState({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { + return ( + +
+
+ {icon} +
+

{title}

+

{body}

+
+
+ ); +} + +function SkeletonPanel({ lines }: { lines: number }) { + return ( + + {Array.from({ length: lines }).map((_, i) => ( +
+ ))} + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 410a073..034ebb9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -13,7 +13,8 @@ import { ShieldAlert, Users, Truck, - Box + Box, + Monitor } from 'lucide-react'; import { NavLink, useLocation } from 'react-router-dom'; import { MainSection } from '../types'; @@ -40,6 +41,7 @@ export default function Sidebar({ { id: 'inventory' as MainSection, label: 'Products', icon: Layers }, { id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp }, { id: 'dispatch' as MainSection, label: 'Console', icon: Truck }, + { id: 'pos' as MainSection, label: 'POS', icon: Monitor }, { id: 'settings' as MainSection, label: 'Settings', icon: Settings } ]; diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index 8056a86..f78df67 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -20,6 +20,7 @@ import { Layers, Users, TrendingUp, + Monitor, X, } from 'lucide-react'; import { @@ -36,6 +37,7 @@ import DispatchHubView from './DispatchHubView'; import DeliveryReportsView from './DeliveryReportsView'; import StoreQRView from './StoreQRView'; import PosView from './PosView'; +import PosConsoleView from './PosConsoleView'; import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar'; import ComparisonModal from './ComparisonModal'; interface UserStorePageProps { @@ -52,6 +54,7 @@ const NAV_ITEMS: UserNavItem[] = [ { id: 'inventory', label: 'Products', icon: Layers }, { id: 'customers', label: 'Customers', icon: Users }, { id: 'dispatch', label: 'Console', icon: RouteIcon }, + { id: 'pos', label: 'POS', icon: Monitor }, { id: 'reports', label: 'Reports', icon: ClipboardList }, ]; @@ -114,6 +117,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { const resolvedLocationId = (matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0; + // The POS reads treat `locationid` as their authorisation boundary, so this + // deliberately does NOT inherit the fallback chain above: `applocationid` is + // a city id, not an outlet, and passing one would scope the query to a store + // that is either empty or — if the ids happen to collide — somebody else's. + // Only a real tenantlocations.locationid is accepted; otherwise the POS page + // says so rather than showing another shop's takings. + const posLocationId = (matchedLoc && fnum(matchedLoc.locationid)) || 0; + const orderSummaryQ = useFiestaOrderSummary(tenantId, todayStr, todayStr, resolvedLocationId || undefined); const sum = orderSummaryQ.data; @@ -256,7 +267,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { // Logistics console — scoped to this user's store. These views own their // loading/error states, so they don't need the store-console load gating below. - if (activeSection === 'pos') return ; + if (activeSection === 'pos') + return ; + if (activeSection === 'pos-till') + return ; if (activeSection === 'dispatch') return ; if (activeSection === 'reports') return ; // Inventory & Catalog is its own page: the manager-curated catalog the user @@ -302,6 +316,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { : activeSection === 'inventory' ? 'Products' : activeSection === 'pos' + ? 'POS' + : activeSection === 'pos-till' ? 'POS Terminal' : activeSection === 'account' ? 'My Account' @@ -313,6 +329,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { : activeSection === 'inventory' ? Layers : activeSection === 'pos' + ? Monitor + : activeSection === 'pos-till' ? ShoppingBag : activeSection === 'customers' ? Users @@ -333,7 +351,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
{isInactive && ( @@ -354,7 +372,16 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { {renderAccount()}
} /> + {/* Counter sales and till health for this user's own store. */} + +
+ } /> + {/* The on-screen till itself. Kept at its own path — it is a + checkout surface, not a reporting one, and shares nothing with + the POS console above. */} +
@@ -426,3 +453,41 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { ); } + +/** + * The store-scoped POS console, behind a strict location gate. + * + * Kept as its own component so the unresolved-outlet case has somewhere honest + * to live. Every POS read is authorised by `locationid` alone, so a page that + * guessed one — from an applocationid, say — would not fail loudly; it would + * quietly show the wrong shop's takings, or an empty board that looks like a + * quiet day. Refusing to render is the safe answer. + */ +function PosStoreSection({ + locationid, + tenantId, + storeName, +}: { + locationid: number; + tenantId: number; + storeName: string; +}) { + if (!locationid) { + return ( +
+
+
+ +
+

No outlet resolved

+

+ Counter sales are scoped to a single outlet, and your account isn’t linked to one yet. + Ask your administrator to allocate you to a store location. +

+
+
+ ); + } + + return ; +} diff --git a/src/services/posApi.ts b/src/services/posApi.ts new file mode 100644 index 0000000..5e6dcbc --- /dev/null +++ b/src/services/posApi.ts @@ -0,0 +1,644 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * POS REST client — the counter-sales surface served at + * `https://fiesta.nearle.app/live/api/v1/pos`. + * + * This is a *different base* from `./fiestaApi` (which is `/live/api/v1/web`), + * so it gets its own client rather than a helper on that one. + * + * Two things about this API shape every function below: + * + * 1. **There is no tenant parameter.** `locationid` is required on every read + * and is the authorisation boundary server-side. A tenant-wide view is + * therefore a fan-out, one request per outlet, merged here — see `mapLimit`. + * + * 2. **The health endpoints return a Redis hash**, so every value is a string — + * `"today_amount": "170"`, `"printer_reachable": "0"`. Nothing may be used + * as a number or a boolean without going through the parsers here. + * + * Components call the hooks in `./posQueries`, not these functions. + */ + +const POS_BASE = import.meta.env.VITE_FIESTA_POS_URL || 'https://fiesta.nearle.app/live/api/v1/pos'; + +type QueryParams = Record; + +// ── Wire types ─────────────────────────────────────────────────────────────── + +/** One counter bill. Mirrors `models.PosOrders` — the list endpoint is a + * `SELECT *`, so every column arrives whether the doc's example shows it or not. */ +export interface PosBill { + posorderid: number; + terminalorderid: string; + invoicenumber: string; + tenantid: number; + locationid: number; + terminalid: string; + cashiername: string; + customerid: number; + customermobile: string; + customername: string; + billedat: string; + businessdate: string; + subtotal: number; + discount: number; + taxamount: number; + roundoff: number; + total: number; + pointsearned: number; + pointsredeemed: number; + itemcount: number; + paymentmode: string; + paymentsjson: string; + promosjson: string; + taxbreakdownjson: string; + batchid: string; + receivedat: string; + created?: string; + updated?: string; + /** Populated only by `/sales/detail`. The list endpoint sends `items: null` + * (the Go field is `gorm:"-"` with no `omitempty`), so this is nullable. */ + items?: PosBillItem[] | null; +} + +export interface PosBillItem { + posorderitemid: number; + posorderid: number; + tenantid: number; + locationid: number; + productid: number; + productname: string; + barcode: string; + unitname: string; + /** Fractional — a counter sells 1.5 kg. */ + quantity: number; + unitprice: number; + discountamount: number; + /** A fraction (0.18), not a percentage (18). */ + gstrate: number; + taxamount: number; + linetotal: number; +} + +export interface PosSalesPage { + total: number; + pageno: number; + pagesize: number; + bills: PosBill[]; +} + +export interface PosPaymentTotal { paymentmode: string; billcount: number; amount: number } +export interface PosDayTotal { businessdate: string; billcount: number; amount: number } +export interface PosTerminalTotal { terminalid: string; billcount: number; amount: number } + +export interface PosSalesSummary { + locationid: number; + fromdate: string; + todate: string; + billcount: number; + itemcount: number; + /** Named "gross" by the API but computed as `SUM(total)` — i.e. **net of + * discount and round-off**. `grosssales + discountgiven` is the gross figure. + * Labelled honestly in the UI as "Net collected". */ + grosssales: number; + taxcollected: number; + discountgiven: number; + roundoff: number; + averagebill: number; + bypaymentmode: PosPaymentTotal[]; + byday: PosDayTotal[]; + byterminal: PosTerminalTotal[]; +} + +/** Raw Redis hash for one till. Every value is a string; use `parseTerminal`. */ +export type PosTerminalRaw = Record; + +export interface PosLocationHealth { + location_id: string; + total: number; + online: number; + terminals: PosTerminalRaw[]; +} + +export interface PosSalesFilter { + locationid: number; + fromdate?: string; + todate?: string; + terminalid?: string; + /** A real filter the handler reads, applied as an exact case-sensitive match. */ + cashiername?: string; + paymentmode?: string; + pageno?: number; + pagesize?: number; +} + +// ── Transport ──────────────────────────────────────────────────────────────── + +async function posGet(endpoint: string, params: QueryParams = {}): Promise { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)); + }); + const query = qs.toString(); + const res = await fetch(`${POS_BASE}/${endpoint}${query ? `?${query}` : ''}`, { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + // 404 from /sales/detail is a legitimate "no such bill at this outlet", + // not a transport failure — surfaced as null by getPosSaleDetail. + throw new Error(`POS ${endpoint} failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; +} + +/** The sales reads use the `{code, status, details}` envelope. Note there is no + * `message` key on success — only on errors. */ +function details(json: unknown): T | null { + if (json && typeof json === 'object' && 'details' in json) { + return (json as { details: T }).details ?? null; + } + return null; +} + +/** + * Run `fn` over `items` with at most `limit` in flight. + * + * The tenant views fan out one request per outlet. The largest tenant on the + * platform has 22 outlets, so an unbounded fan-out would open 44 sockets at + * once (health + summary) on every refresh. Six keeps it civil without making + * the page feel serial. + */ +export async function mapLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const out = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = cursor++; + if (i >= items.length) return; + out[i] = await fn(items[i], i); + } + }); + await Promise.all(workers); + return out; +} + +// ── Reads ──────────────────────────────────────────────────────────────────── + +/** + * A page of bills for one outlet, newest first. + * + * `pagesize` is clamped to 500 here because the server does *not* clamp — it + * resets anything over 500 back to 50, so asking for 1000 quietly returns 50. + */ +export async function getPosSales(filter: PosSalesFilter): Promise { + const pagesize = Math.min(Math.max(filter.pagesize ?? 50, 1), 500); + const json = await posGet('sales', { + locationid: filter.locationid, + fromdate: filter.fromdate, + todate: filter.todate, + terminalid: filter.terminalid, + cashiername: filter.cashiername, + paymentmode: filter.paymentmode, + pageno: filter.pageno ?? 0, + pagesize, + }); + return ( + details(json) ?? { total: 0, pageno: 0, pagesize, bills: [] } + ); +} + +/** + * One bill with its lines. `reference` matches any of three columns server-side + * — the terminal's UUID, the invoice number, or the posorderid — so a support + * call can start from whichever the caller is looking at. + * + * Returns null on 404, which is what the server sends when the reference is + * real but belongs to a different outlet. + */ +export async function getPosSaleDetail( + locationid: number, + reference: string, +): Promise { + try { + const json = await posGet('sales/detail', { locationid, reference }); + return details(json); + } catch { + return null; + } +} + +export async function getPosSalesSummary(filter: PosSalesFilter): Promise { + const json = await posGet('sales/summary', { + locationid: filter.locationid, + fromdate: filter.fromdate, + todate: filter.todate, + terminalid: filter.terminalid, + cashiername: filter.cashiername, + paymentmode: filter.paymentmode, + }); + return details(json); +} + +/** One product exactly as a till stores it. */ +export interface PosCatalogueProduct { + id: string; + name: string; + barcode: string; + sku: string; + category: string; + price: number; + mrp?: number; + stock: number; + unit: string; + /** A fraction (0.05), matching how the till holds it. */ + gst_rate: number; + hsn_code?: string; + brand?: string; + /** False for anything a till must not be able to ring up — chiefly unpriced + * products, which would otherwise sell at ₹0. */ + is_active: boolean; +} + +export interface PosCatalogueCustomer { + id: string; + name: string; + mobile: string; + loyalty_points: number; + lifetime_spend: number; + visit_count: number; + last_visit_at?: string; +} + +export interface PosCatalogue { + revision: string; + is_delta: boolean; + has_more: boolean; + products: PosCatalogueProduct[]; + customers: PosCatalogueCustomer[]; + retired_product_ids: string[]; +} + +/** + * What a till at this store currently sees. + * + * Answered as a **bare body**, not the `{code, status, details}` envelope the + * sales reads use — it is a terminal-facing endpoint, so it is parsed directly. + * + * Always called without `since`, which yields a full snapshot. A delta would be + * cheaper but describes only what changed, and this panel is asking what the + * till can sell *right now*. + */ +export async function getPosCatalogue(storeId: number, pageSize = 500): Promise { + const json = await posGet('catalogue', { store_id: storeId, page_size: pageSize }); + return { + revision: json?.revision ?? '', + is_delta: Boolean(json?.is_delta), + has_more: Boolean(json?.has_more), + products: json?.products ?? [], + customers: json?.customers ?? [], + retired_product_ids: json?.retired_product_ids ?? [], + }; +} + +export async function getPosLocationHealth(locationid: number): Promise { + const json = await posGet('health/location', { location_id: locationid }); + return ( + details(json) ?? { + location_id: String(locationid), + total: 0, + online: 0, + terminals: [], + } + ); +} + +// ── Parsing the health hash ────────────────────────────────────────────────── + +/** + * Four states, not two. + * + * The heartbeat is every 30s under a 90s TTL, so between those a live till has + * no fresh reading and is *not* dead — showing two states would make healthy + * tills blink red. And a key can exist while saying "offline", because the + * broker's Last Will overwrites the status when a till loses power mid-shift. + * That is the only way to tell *closed for the night* from *unplugged*, so it + * is kept distinct from a key that simply expired. + */ +export type TerminalState = 'online' | 'stale' | 'offline_declared' | 'offline_vanished'; + +export interface PosTerminal { + terminalId: string; + locationId: string; + storeName: string; + appVersion: string; + state: TerminalState; + /** Server-supplied explanation on the expired-key stub. */ + reason: string; + pendingBills: number; + pendingRegistrations: number; + oldestPendingAt: string; + todayBills: number; + todayAmount: number; + lastBillAt: string; + reportedAt: string; + receivedAt: string; + /** Seconds since the *server* stamped the reading. Deliberately measured from + * `received_at`, never `reported_at` — a till with a wrong clock must not be + * able to make itself look fresh. */ + ageSeconds: number | null; + /** `reported_at` minus `received_at`, in seconds. A large value means the + * till's clock is wrong, which matters because bills are filed under the + * business date the till decided. */ + clockDriftSeconds: number | null; + /** Absent when the till does not collect them — never coerced to zero, or a + * board would show every terminal on a flat battery. */ + batteryLevel: number | null; + batteryCharging: boolean | null; + storageFreeMb: number | null; + printerReachable: boolean | null; + drawerStatus: string | null; +} + +const s = (h: PosTerminalRaw, k: string): string => (h[k] ?? '').trim(); + +function optNum(h: PosTerminalRaw, k: string): number | null { + const raw = s(h, k); + if (raw === '') return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +/** Redis stores a Go bool as "1"/"0"; tolerate "true"/"false" too. */ +function optBool(h: PosTerminalRaw, k: string): boolean | null { + const raw = s(h, k).toLowerCase(); + if (raw === '') return null; + return raw === '1' || raw === 'true'; +} + +function secondsSince(iso: string, now: number): number | null { + if (!iso) return null; + const t = Date.parse(iso); + return Number.isFinite(t) ? Math.round((now - t) / 1000) : null; +} + +export function parseTerminal(h: PosTerminalRaw, now: number = Date.now()): PosTerminal { + const status = s(h, 'status').toLowerCase(); + const reason = s(h, 'reason'); + const receivedAt = s(h, 'received_at'); + const reportedAt = s(h, 'reported_at'); + const ageSeconds = secondsSince(receivedAt, now); + + // The expired-key stub is exactly {terminal_id, location_id, status, reason} + // — it carries no heartbeat fields at all, which is how it's told apart from + // a till that is present and declaring itself offline. + const isStub = status === 'offline' && reason !== '' && receivedAt === ''; + + let state: TerminalState; + if (isStub) state = 'offline_vanished'; + else if (status !== 'online') state = 'offline_declared'; + else if (ageSeconds == null || ageSeconds <= 40) state = 'online'; + else state = 'stale'; + + const reportedT = reportedAt ? Date.parse(reportedAt) : NaN; + const receivedT = receivedAt ? Date.parse(receivedAt) : NaN; + + return { + terminalId: s(h, 'terminal_id'), + locationId: s(h, 'location_id'), + storeName: s(h, 'store_name'), + appVersion: s(h, 'app_version'), + state, + reason, + pendingBills: optNum(h, 'pending_bills') ?? 0, + pendingRegistrations: optNum(h, 'pending_registrations') ?? 0, + oldestPendingAt: s(h, 'oldest_pending_at'), + todayBills: optNum(h, 'today_bills') ?? 0, + todayAmount: optNum(h, 'today_amount') ?? 0, + lastBillAt: s(h, 'last_bill_at'), + reportedAt, + receivedAt, + ageSeconds, + clockDriftSeconds: + Number.isFinite(reportedT) && Number.isFinite(receivedT) + ? Math.round((reportedT - receivedT) / 1000) + : null, + batteryLevel: optNum(h, 'battery_level'), + batteryCharging: optBool(h, 'battery_charging'), + storageFreeMb: optNum(h, 'storage_free_mb'), + printerReachable: optBool(h, 'printer_reachable'), + drawerStatus: s(h, 'drawer_status') || null, + }; +} + +export const TERMINAL_STATE_LABEL: Record = { + online: 'Online', + stale: 'Stale', + offline_declared: 'Offline', + offline_vanished: 'No heartbeat', +}; + +export const TERMINAL_STATE_COLOR: Record = { + online: '#10b981', + stale: '#f59e0b', + offline_declared: '#ef4444', + offline_vanished: '#94a3b8', +}; + +// ── Merging a fan-out ──────────────────────────────────────────────────────── + +export interface PosOutletSummary { + locationid: number; + name: string; + summary: PosSalesSummary | null; +} + +/** + * Fold per-outlet summaries into one. + * + * `averagebill` is **recomputed** as `Σtotal / Σbills`, never averaged. Taking + * the mean of twelve outlets' averages weights a four-bill kiosk the same as a + * nine-hundred-bill store, which is not a number anyone should act on. + */ +export function mergeSummaries(parts: Array): PosSalesSummary { + const merged: PosSalesSummary = { + locationid: 0, + fromdate: '', + todate: '', + billcount: 0, + itemcount: 0, + grosssales: 0, + taxcollected: 0, + discountgiven: 0, + roundoff: 0, + averagebill: 0, + bypaymentmode: [], + byday: [], + byterminal: [], + }; + + const byMode = new Map(); + const byDay = new Map(); + const byTerm = new Map(); + + for (const p of parts) { + if (!p) continue; + merged.billcount += p.billcount || 0; + merged.itemcount += p.itemcount || 0; + merged.grosssales += p.grosssales || 0; + merged.taxcollected += p.taxcollected || 0; + merged.discountgiven += p.discountgiven || 0; + merged.roundoff += p.roundoff || 0; + merged.fromdate ||= p.fromdate; + merged.todate ||= p.todate; + + for (const m of p.bypaymentmode ?? []) { + const key = m.paymentmode || ''; + const at = byMode.get(key) ?? { paymentmode: key, billcount: 0, amount: 0 }; + at.billcount += m.billcount || 0; + at.amount += m.amount || 0; + byMode.set(key, at); + } + for (const d of p.byday ?? []) { + const at = byDay.get(d.businessdate) ?? { businessdate: d.businessdate, billcount: 0, amount: 0 }; + at.billcount += d.billcount || 0; + at.amount += d.amount || 0; + byDay.set(d.businessdate, at); + } + for (const t of p.byterminal ?? []) { + const key = t.terminalid || ''; + const at = byTerm.get(key) ?? { terminalid: key, billcount: 0, amount: 0 }; + at.billcount += t.billcount || 0; + at.amount += t.amount || 0; + byTerm.set(key, at); + } + } + + merged.averagebill = merged.billcount > 0 ? merged.grosssales / merged.billcount : 0; + merged.bypaymentmode = [...byMode.values()].sort((a, b) => b.amount - a.amount); + merged.byday = [...byDay.values()].sort((a, b) => a.businessdate.localeCompare(b.businessdate)); + merged.byterminal = [...byTerm.values()].sort((a, b) => b.amount - a.amount); + return merged; +} + +/** Average bill for one outlet, recomputed rather than trusted — same trap as + * the merge above, and the store leaderboard is where it would bite. */ +export const averageBill = (sm: PosSalesSummary | null): number => + sm && sm.billcount > 0 ? sm.grosssales / sm.billcount : 0; + +// ── Bill helpers ───────────────────────────────────────────────────────────── + +export interface PosPaymentSplit { method: string; amount: number; reference?: string } +export interface PosPromo { id?: string; name?: string; type?: string; amount?: number } + +function parseJsonField(raw: string | undefined, fallback: T): T { + if (!raw) return fallback; + try { + const v = JSON.parse(raw); + return (v ?? fallback) as T; + } catch { + return fallback; + } +} + +/** The full tender split. A bill can be part cash, part card, part loyalty, and + * `paymentmode` only names the largest of them. */ +export const billPayments = (b: PosBill): PosPaymentSplit[] => + parseJsonField(b.paymentsjson, []); + +/** GST per slab, as printed on the tax invoice. Keys are rate fractions + * ("0.05"), values are the tax amount at that slab. */ +export const billTaxBreakdown = (b: PosBill): Array<{ rate: number; amount: number }> => { + const raw = parseJsonField>(b.taxbreakdownjson, {}); + return Object.entries(raw) + .map(([rate, amount]) => ({ rate: Number(rate), amount: Number(amount) })) + .filter((r) => Number.isFinite(r.rate) && Number.isFinite(r.amount)) + // Tills send a `{"0.0": 0}` placeholder on zero-rated bills. Rendering it + // as a "0.00% — ₹0.00" slab reads like a real tax line for an untaxed sale. + .filter((r) => r.amount !== 0 || r.rate !== 0) + .sort((a, b2) => a.rate - b2.rate); +}; + +/** Who the bill was rung for. A till can attach a customer id without a name or + * mobile, so "Walk-in" is reserved for bills with no customer at all. */ +export function billCustomer(b: PosBill): string { + const named = (b.customername || '').trim() || (b.customermobile || '').trim(); + if (named) return named; + return b.customerid > 0 ? `Customer #${b.customerid}` : 'Walk-in'; +} + +export const billPromos = (b: PosBill): PosPromo[] => parseJsonField(b.promosjson, []); + +export type SyncLag = + | { kind: 'legacy'; seconds: number } + | { kind: 'ok'; seconds: number } + | { kind: 'delayed'; seconds: number } + | { kind: 'unknown' }; + +/** + * How long a bill took to reach us. + * + * A negative value is **not** a fast upload — it is a bill written before the + * till started sending a UTC offset, whose `billedat` holds IST wall-clock + * stamped as though it were UTC. Those come back exactly 5h30m "early". They + * are classed `legacy` rather than alerted on, because flooring the panel at a + * date would still leak the ones rung on the morning of the fix. + * + * `businessdate` is unaffected by that skew (storing local wall-clock as UTC + * preserves the date), so day bucketing and the date filters stay correct — the + * damage is confined to this one comparison. + */ +export function syncLag(b: PosBill): SyncLag { + const billed = Date.parse(b.billedat); + const received = Date.parse(b.receivedat); + if (!Number.isFinite(billed) || !Number.isFinite(received)) return { kind: 'unknown' }; + const seconds = Math.round((received - billed) / 1000); + if (seconds < 0) return { kind: 'legacy', seconds }; + if (seconds > 900) return { kind: 'delayed', seconds }; + return { kind: 'ok', seconds }; +} + +/** Terminal ids are blank on bills ingested before the fallback landed. Shown + * as a named bucket rather than an empty cell so the column reads honestly. */ +export const terminalLabel = (t: string): string => (t && t.trim()) || 'Unassigned'; + +export const todayISO = (): string => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +export const daysAgoISO = (days: number): string => { + const d = new Date(); + d.setDate(d.getDate() - days); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +export const inr = (n: number): string => + `₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +export const inrCompact = (n: number): string => + `₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { maximumFractionDigits: 0 })}`; + +/** Duration in the units a person reading a sync board actually wants. */ +export function humanDuration(seconds: number): string { + const abs = Math.abs(seconds); + if (abs < 60) return `${abs}s`; + if (abs < 3600) return `${Math.round(abs / 60)}m`; + if (abs < 86400) return `${(abs / 3600).toFixed(1)}h`; + return `${(abs / 86400).toFixed(1)}d`; +} + +/** `2026-08-05T17:29:00Z` → `17:29`. Rendered as sent; no timezone maths, since + * the stored value's offset is not trustworthy across the fix boundary. */ +export function billTime(iso: string): string { + if (!iso) return '—'; + const m = iso.match(/T(\d{2}:\d{2})/); + return m ? m[1] : '—'; +} diff --git a/src/services/posQueries.ts b/src/services/posQueries.ts new file mode 100644 index 0000000..229708e --- /dev/null +++ b/src/services/posQueries.ts @@ -0,0 +1,176 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TanStack Query hooks over the POS client in `./posApi`. + * + * The tenant-wide hooks fan out — one request per outlet, six in flight — and + * merge here, because the POS API has no tenant parameter. That is deliberate + * for now: the largest tenant on the platform has 22 outlets, so the worst case + * is 44 requests per refresh, which is comfortable. A `GetTenantSalesSummary` + * on the backend would collapse it to one, and is worth adding once there is a + * measurement that justifies it. + */ + +import { useQuery } from '@tanstack/react-query'; +import { + getPosLocationHealth, + getPosSales, + getPosSaleDetail, + getPosCatalogue, + getPosSalesSummary, + mapLimit, + mergeSummaries, + parseTerminal, + type PosBill, + type PosCatalogue, + type PosSalesFilter, + type PosSalesPage, + type PosSalesSummary, + type PosTerminal, +} from './posApi'; + +/** Heartbeats land every 30s, so polling faster only burns requests. */ +const HEALTH_POLL_MS = 30_000; +const FANOUT_LIMIT = 6; + +export const posKeys = { + all: ['pos'] as const, + health: (ids: number[]) => [...posKeys.all, 'health', ids.join(',')] as const, + summary: (ids: number[], from: string, to: string) => + [...posKeys.all, 'summary', ids.join(','), from, to] as const, + sales: (f: PosSalesFilter) => + [ + ...posKeys.all, + 'sales', + f.locationid, + f.fromdate ?? '', + f.todate ?? '', + f.terminalid ?? '', + f.cashiername ?? '', + f.paymentmode ?? '', + f.pageno ?? 0, + f.pagesize ?? 50, + ] as const, + catalogue: (locationid: number) => [...posKeys.all, 'catalogue', locationid] as const, + detail: (locationid: number, reference: string) => + [...posKeys.all, 'detail', locationid, reference] as const, +}; + +/** One outlet's tills, with the outlet id kept alongside so a tenant board can + * attribute a terminal to a store without a second lookup. */ +export interface PosOutletHealth { + locationid: number; + total: number; + online: number; + terminals: PosTerminal[]; +} + +/** + * Terminal health across one or more outlets. + * + * Parsing happens in the query function rather than the component so the Redis + * strings are converted exactly once per fetch, not on every render. + */ +export function usePosFleetHealth(locationIds: number[]) { + const ids = [...new Set(locationIds.filter((n) => n > 0))].sort((a, b) => a - b); + return useQuery({ + queryKey: posKeys.health(ids), + enabled: ids.length > 0, + refetchInterval: HEALTH_POLL_MS, + queryFn: async (): Promise => { + const now = Date.now(); + return mapLimit(ids, FANOUT_LIMIT, async (locationid) => { + try { + const raw = await getPosLocationHealth(locationid); + const terminals = (raw.terminals ?? []).map((t) => parseTerminal(t, now)); + // `online` is recounted here rather than trusted: the server counts + // status === "online", which cannot distinguish a fresh heartbeat + // from one that is about to expire. + return { + locationid, + total: terminals.length, + online: terminals.filter((t) => t.state === 'online').length, + terminals, + }; + } catch { + // One outlet's Redis being unreachable must not blank the board for + // every other shop. + return { locationid, total: 0, online: 0, terminals: [] }; + } + }); + }, + }); +} + +export interface PosScopedSummary { + /** Per-outlet, for a leaderboard. Null where the outlet has no POS data. */ + perOutlet: Array<{ locationid: number; summary: PosSalesSummary | null }>; + /** All outlets folded together, with `averagebill` recomputed. */ + merged: PosSalesSummary; +} + +/** + * Sales totals for a date range across one or more outlets. + * + * The merge recomputes `averagebill` from the summed totals — see + * `mergeSummaries`. + */ +export function usePosSalesSummary(locationIds: number[], fromdate: string, todate: string) { + const ids = [...new Set(locationIds.filter((n) => n > 0))].sort((a, b) => a - b); + return useQuery({ + queryKey: posKeys.summary(ids, fromdate, todate), + enabled: ids.length > 0 && Boolean(fromdate) && Boolean(todate), + queryFn: async (): Promise => { + const perOutlet = await mapLimit(ids, FANOUT_LIMIT, async (locationid) => { + try { + return { locationid, summary: await getPosSalesSummary({ locationid, fromdate, todate }) }; + } catch { + return { locationid, summary: null }; + } + }); + return { perOutlet, merged: mergeSummaries(perOutlet.map((p) => p.summary)) }; + }, + }); +} + +/** A page of bills for one outlet. Bills are always single-outlet: `locationid` + * is the authorisation boundary and the API takes exactly one. */ +export function usePosSales(filter: PosSalesFilter) { + return useQuery({ + queryKey: posKeys.sales(filter), + enabled: filter.locationid > 0, + queryFn: (): Promise => getPosSales(filter), + placeholderData: (prev) => prev, + }); +} + +/** One bill with its lines. Resolves to null when the reference does not belong + * to this outlet — the server answers 404 for that case on purpose. */ +export function usePosSaleDetail(locationid: number, reference: string) { + return useQuery({ + queryKey: posKeys.detail(locationid, reference), + enabled: locationid > 0 && reference.trim().length > 0, + queryFn: (): Promise => getPosSaleDetail(locationid, reference), + }); +} + +/** + * The product snapshot a till at this store is working from. + * + * Deliberately **not** fanned out across the tenant and gated on `enabled`, so + * it is fetched for one outlet only and only once its panel is opened. A + * snapshot is the whole shelf, and pulling one per outlet on every page load + * would be the heaviest thing on the page for a diagnostic most visits never + * look at. + */ +export function usePosCatalogue(locationid: number, enabled: boolean) { + return useQuery({ + queryKey: posKeys.catalogue(locationid), + enabled: enabled && locationid > 0, + staleTime: 60_000, + queryFn: (): Promise => getPosCatalogue(locationid), + }); +} diff --git a/src/types.ts b/src/types.ts index d12824f..d4e1a23 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch' | 'catalogue'; +export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch' | 'catalogue' | 'pos'; export interface KPICardData { title: string;