import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { Badge } from '@astryxdesign/core/Badge'; import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { AlertTriangle, ArrowRight, ClipboardList, Monitor, PackageX, Receipt, ShoppingCart, TrendingDown, } from 'lucide-react'; import type { DateRange } from '@/api/insights'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; import { SectionHeader } from '@/components/SectionHeader'; import { useLocationSummary, usePosHealthByBranch, usePosSalesByBranch, useStockRequests, } from '@/queries/hooks'; import { useBranchScope } from '../BranchScope'; import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker'; import { branchLabel, count, money, percent, share } from '../format'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { SYNC_COLOR, SYNC_LABEL, SYNC_TONE, shortAge, summariseBranch, type BranchSyncSummary, type TerminalStatus, } from '../posStatus'; /** * The Store Admin's landing page. * * One rule governs the whole screen: **app sales and counter sales are never * added together.** They come from two different tables — `orders` and * `posorders` — and a branch that both runs a till and uploads a sales * spreadsheet would be counted twice by a combined total. There is no headline * "Total Revenue" tile here for that reason, and its absence is deliberate * rather than an omission. See `store-admin-backend-gap.md` §3.1. */ export function ConsolePage() { const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope(); /** * Where this page's links point. * * The page is shared with the Store user workspace, pinned to one branch, and * a hardcoded `/admin/...` there sends a shop to a workspace its role cannot * enter — which the route guard answers with a redirect, so the alert silently * does nothing. */ const base = isPinned ? '/store' : '/admin'; const [preset, setPreset] = useState('today'); const [range, setRange] = useState(() => presetRange('today')); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); const orders = useLocationSummary(tenantid || undefined); const posSales = usePosSalesByBranch(branchIds, range); const posHealth = usePosHealthByBranch(branchIds); const requests = useStockRequests( tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined, ); // One instant for the whole board — see `summariseBranch`. const now = Date.now(); /** Everything the page needs, per branch, assembled once. */ const rows = useMemo( () => scoped.map((branch, index) => { const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid); const pos = posSales[index]?.data; const health = summariseBranch(posHealth[index]?.data ?? [], now); return { branch, onlineRevenue: order?.revenue ?? 0, onlineOrders: order?.totalorders ?? 0, cancelled: order?.cancelled ?? 0, delivered: order?.delivered ?? 0, counterRevenue: pos?.grosssales ?? 0, counterBills: pos?.billcount ?? 0, health, pendingRequests: (requests.data ?? []).filter( (entry) => entry.locationid === branch.locationid, ).length, }; }), [scoped, orders.data, posSales, posHealth, requests.data, now], ); const totals = useMemo( () => rows.reduce( (acc, row) => ({ onlineRevenue: acc.onlineRevenue + row.onlineRevenue, onlineOrders: acc.onlineOrders + row.onlineOrders, cancelled: acc.cancelled + row.cancelled, counterRevenue: acc.counterRevenue + row.counterRevenue, counterBills: acc.counterBills + row.counterBills, pendingRequests: acc.pendingRequests + row.pendingRequests, pendingBills: acc.pendingBills + row.health.pendingBills, }), { onlineRevenue: 0, onlineOrders: 0, cancelled: 0, counterRevenue: 0, counterBills: 0, pendingRequests: 0, pendingBills: 0, }, ), [rows], ); const alerts = useMemo(() => buildAlerts(rows, base, isPinned), [rows, base, isPinned]); const cancelShare = share(totals.cancelled, totals.onlineOrders); return ( { setPreset(nextPreset); setRange(nextRange); }} /> } /> {/* ── Revenue, by channel, never summed ──────────────────────────── */}
} /> } /> 0.1 ? 'error' : 'neutral'} icon={} fill={cancelShare ?? undefined} /> 0 ? 'warning' : 'success'} icon={} />
{/* ── Branch overview ─────────────────────────────────────────────── */} {isLoading ? ( Loading branches… ) : rows.length === 0 ? ( No branches yet Commission your first outlet and its numbers will appear here. ) : (
{rows.map((row) => ( ))}
)}
{/* ── Comparison, only when there is something to compare ─────────── */} {rows.length > 1 ? (
) : null} {/* ── Tills ───────────────────────────────────────────────────────── */} Tills report every 30s } />
{/* ── Action required ─────────────────────────────────────────────── */} {alerts.length === 0 ? ( Nothing waiting Every till is synced and no stock request is outstanding. ) : ( {alerts.map((alert) => ( ))} )}
); } /* ────────────────────────────────────────────────────────────────────────── */ type Row = { branch: { locationid: number; locationname: string; suburb?: string; city?: string; status: string }; onlineRevenue: number; onlineOrders: number; cancelled: number; delivered: number; counterRevenue: number; counterBills: number; health: ReturnType; pendingRequests: number; }; function BranchCard({ row }: { row: Row }) { const { branch, health } = row; const isActive = branch.status?.toLowerCase() === 'active'; return ( {branch.locationname} {branch.suburb ?? branch.city ?? '—'} {/* Two channels, two rows, never a sum. */}
{/* Plain words, and a link. This line used to read "Stale · 2/3 tills" — three pieces of jargon in four words, on the page a merchant lands on. It is now a sentence, and it goes somewhere. */} {tillLine(health)} {row.pendingRequests > 0 ? ( ) : null} ); } /** * "2 counters need a look", not "Stale · 2/3 tills". * * Counts what a person would count: how many counters we cannot currently see, * and whether any of them is holding sales. Everything else is a detail for the * Counters page, which this line links to. */ function tillLine(health: BranchSyncSummary): string { if (health.total === 0) return 'No counter reporting'; const absent = health.terminals.filter((t) => t.presence !== 'online').length; if (absent === 0) { return health.pendingBills > 0 ? `All counters on · ${health.pendingBills} sales still sending` : `All ${health.total} counter${health.total === 1 ? '' : 's'} fine`; } const stuck = health.terminals .filter((t) => t.presence !== 'online') .reduce((sum, t) => sum + t.pendingBills, 0); if (stuck > 0) { return `${stuck} sale${stuck === 1 ? '' : 's'} stuck on ${absent} counter${absent === 1 ? '' : 's'}`; } return `${absent} counter${absent === 1 ? '' : 's'} not answering`; } function ChannelRow({ label, value, note }: { label: string; value: string; note: string }) { return ( {label} {value} {note} ); } /** * The comparison table. * * Metrics run down and branches run across, which is the transpose of a normal * data table and the right way round here: the question is "which branch is * behind on revenue", and that is a comparison along a row. */ function ComparisonTable({ rows }: { rows: Row[] }) { const metrics: { label: string; get: (row: Row) => string; total: string }[] = [ { label: 'App revenue', get: (row) => money(row.onlineRevenue), total: money(rows.reduce((sum, row) => sum + row.onlineRevenue, 0)), }, { label: 'Counter revenue', get: (row) => money(row.counterRevenue), total: money(rows.reduce((sum, row) => sum + row.counterRevenue, 0)), }, { label: 'App orders', get: (row) => count(row.onlineOrders), total: count(rows.reduce((sum, row) => sum + row.onlineOrders, 0)), }, { label: 'Counter bills', get: (row) => count(row.counterBills), total: count(rows.reduce((sum, row) => sum + row.counterBills, 0)), }, { label: 'Cancelled', get: (row) => percent(share(row.cancelled, row.onlineOrders)), total: percent( share( rows.reduce((sum, row) => sum + row.cancelled, 0), rows.reduce((sum, row) => sum + row.onlineOrders, 0), ), ), }, { label: 'Awaiting approval', get: (row) => count(row.pendingRequests), total: count(rows.reduce((sum, row) => sum + row.pendingRequests, 0)), }, ]; return ( {rows.map((row) => ( ))} {metrics.map((metric) => ( {rows.map((row) => ( ))} ))}
Metric {branchLabel(row.branch.locationname)} All
{metric.label} {metric.get(row)} {metric.total}
); } /** * The device readings, in words rather than as flags. * * `posStatus` no longer pre-formats a `warnings` array, because the strings it * produced ("Storage low (140 MB)") were written for a status board and are * read here by whoever is scanning a table. Each reading is nullable on * purpose — `models/poshealth.go` uses pointers so that "not reported" stays * distinguishable from "reported as zero" — so absent must never render. */ function deviceNote(terminal: TerminalStatus): string { const notes: string[] = []; if (terminal.printerReachable === false) notes.push('Printer not responding'); if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) { notes.push(`Low space (${terminal.storageFreeMb} MB)`); } if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) { notes.push(`Battery ${terminal.batteryLevel}%`); } if (terminal.drawerStatus === 'open') notes.push('Drawer open'); return notes.length > 0 ? notes.join(' · ') : '—'; } function TillTable({ rows }: { rows: Row[] }) { const terminals = rows.flatMap((row) => row.health.terminals.map((terminal) => ({ branch: row.branch.locationname, terminal })), ); const paged = usePaged(terminals); if (terminals.length === 0) { return ( No till is reporting A terminal disappears from this board within seconds of going quiet, so an empty board means nothing is currently reaching us — not that nothing is selling. ); } return ( <> {paged.rows.map(({ branch, terminal }) => ( ))}
Till Branch Status Queued Oldest Today Notes
{terminal.terminalId} {branchLabel(branch)} 0}> {terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'} {shortAge(terminal.oldestPendingMs)} {count(terminal.todayBills)} · {money(terminal.todayAmount)} {deviceNote(terminal)}
); } /* ── Alerts ──────────────────────────────────────────────────────────────── */ interface Alert { id: string; tone: 'error' | 'warning' | 'accent'; icon: React.ReactNode; title: string; detail: string; to: string; action: string; } /** * What needs the admin, ordered by what costs money soonest. * * Stranded takings first: an unsynced till is money already collected that the * business cannot see, and it is the only item here that gets worse purely by * being ignored. */ function buildAlerts(rows: Row[], base: string, isPinned: boolean): Alert[] { const alerts: Alert[] = []; for (const row of rows) { const stranded = row.health.terminals.filter((terminal) => terminal.pendingBills > 0); if (stranded.length > 0) { const bills = stranded.reduce((sum, terminal) => sum + terminal.pendingBills, 0); const oldest = Math.max(...stranded.map((terminal) => terminal.oldestPendingMs)); // Name the till, not just the shop. "Something is wrong at Peelamedu" // makes someone go and look; "PLM-02 is holding 29 bills" tells them // which counter to walk to. const worst = stranded.reduce((acc, terminal) => terminal.oldestPendingMs > acc.oldestPendingMs ? terminal : acc, ); alerts.push({ id: `sync:${row.branch.locationid}`, tone: oldest > 30 * 60 * 1000 ? 'error' : 'warning', icon: , title: `${count(bills)} bill${bills === 1 ? '' : 's'} stranded at ${row.branch.locationname}`, detail: `${stranded.length > 1 ? `${stranded.length} tills, oldest on ` : ''}${worst.terminalId}, waiting ${shortAge(oldest)}. Takings already collected that the business cannot see.`, to: `${base}/terminals`, action: 'Open Terminals', }); } } /** * Tills we cannot see. * * Presence, not the old `state === 'offline'` test — that check predates * `stale` and `no-heartbeat`, so a till whose heartbeat simply stopped would * have raised nothing here at all. Which was the bug in miniature. */ const unseen = rows.flatMap((row) => row.health.terminals .filter((terminal) => terminal.presence !== 'online' && terminal.pendingBills === 0) .map((terminal) => ({ row, terminal })), ); if (unseen.length > 0) { const branches = [...new Set(unseen.map(({ row }) => row.branch.locationname))]; alerts.push({ id: 'unseen', tone: 'warning', icon: , title: `${unseen.length} till${unseen.length === 1 ? '' : 's'} not reporting`, detail: unseen .map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`) .join(', ') .concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`), to: `${base}/terminals`, action: 'See tills', }); } const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0); if (waiting > 0) { // Same fact, opposite side of the counter. The merchant is the one holding // it up; the shop is the one waiting — and a shop told to "approve" a // request it raised itself would go looking for a button that is not there, // because the backend gives it to nobody but the tenant. alerts.push( isPinned ? { id: 'requests', tone: 'warning', icon: , title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} waiting on head office`, detail: 'Your store administrator approves these. Confirm the goods here once they arrive.', to: `${base}/products`, action: 'Open Products', } : { id: 'requests', tone: 'warning', icon: , title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`, detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.', to: '/admin/inventory', action: 'Review requests', }, ); } for (const row of rows) { const cancelRate = share(row.cancelled, row.onlineOrders); if (cancelRate !== null && cancelRate > 0.25 && row.onlineOrders >= 10) { alerts.push({ id: `cancel:${row.branch.locationid}`, tone: 'error', icon: , title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`, detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`, to: `${base}/sales`, action: 'Open Sales', }); } } return alerts; } function AlertRow({ alert }: { alert: Alert }) { const color = alert.tone === 'error' ? 'var(--color-error, #d64545)' : alert.tone === 'warning' ? 'var(--color-warning, #b7860b)' : 'var(--color-brand)'; return ( {alert.icon} {alert.title} {alert.detail}