import type { TenantLocation } from '@/api/types'; import type { BranchSyncSummary } from '@/features/store-admin/posStatus'; /** * The board's arithmetic, separated from its layout. * * Every number on the Console is derived from the same per-branch rows, and the * aggregate is a sum of exactly the rows in scope — never a separate query with * its own idea of the total. That is what makes "All branches" honest: the sum * shown is the sum of the branches listed under it. */ export interface BranchRow { branch: TenantLocation; onlineRevenue: number; onlineOrders: number; cancelled: number; delivered: number; counterRevenue: number; counterBills: number; health: BranchSyncSummary; pendingRequests: number; } export interface ConsoleTotals { onlineRevenue: number; onlineOrders: number; cancelled: number; counterRevenue: number; counterBills: number; pendingBills: number; totalRevenue: number; totalOrders: number; tillsOnline: number; tillsTotal: number; } export function totalsOf(rows: readonly BranchRow[]): ConsoleTotals { const totals = 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, pendingBills: acc.pendingBills + row.health.pendingBills, totalRevenue: 0, totalOrders: 0, tillsOnline: acc.tillsOnline + row.health.online, tillsTotal: acc.tillsTotal + row.health.total, }), { onlineRevenue: 0, onlineOrders: 0, cancelled: 0, counterRevenue: 0, counterBills: 0, pendingBills: 0, totalRevenue: 0, totalOrders: 0, tillsOnline: 0, tillsTotal: 0, }, ); // Derived last so the two channels cannot disagree with their own sum. totals.totalRevenue = totals.onlineRevenue + totals.counterRevenue; totals.totalOrders = totals.onlineOrders + totals.counterBills; return totals; } /* ── Health ───────────────────────────────────────────────────────────────── */ export type HealthTone = 'healthy' | 'attention' | 'critical'; /** * How one branch is doing, in three words rather than six states. * * `posStatus` distinguishes synced/syncing/delayed/stale/offline because a till * screen needs that much detail. A dashboard does not: the question here is * whether somebody has to go and do something, so the states collapse to fine, * look at it, or it is down. */ export function branchTone(health: BranchSyncSummary): HealthTone { if (health.total === 0) return 'attention'; if (health.online === 0) return 'critical'; if (health.state === 'stale' || health.state === 'offline') return 'critical'; if (health.state === 'delayed' || health.online < health.total) return 'attention'; return 'healthy'; } /* ── Attention ────────────────────────────────────────────────────────────── */ export interface ConsoleAlert { id: string; severity: HealthTone; branch: string; title: string; detail: string; actionLabel: string; href: string; } /** * What somebody has to do something about, worst first. * * Only real conditions, and each one names the branch even on a single-branch * board — an alert a merchant might screenshot and send to a colleague should * say where it came from. * * Deliberately not "every anomaly". A dashboard that lists thirty warnings * teaches people to scroll past the section, and then the one that mattered is * scrolled past too. */ export function buildAlerts(rows: readonly BranchRow[], base: string): ConsoleAlert[] { const alerts: ConsoleAlert[] = []; for (const row of rows) { const name = row.branch.locationname?.trim() || `Branch ${row.branch.locationid}`; const health = row.health; if (health.total > 0 && health.online === 0) { alerts.push({ id: `tills-down-${row.branch.locationid}`, severity: 'critical', branch: name, title: health.total === 1 ? 'Till not reporting' : `${health.total} tills not reporting`, detail: 'No heartbeat, so counter sales are not reaching the books.', actionLabel: 'Check tills', href: `${base}/terminals`, }); } else if (health.online < health.total) { alerts.push({ id: `tills-part-${row.branch.locationid}`, severity: 'attention', branch: name, title: `${health.total - health.online} of ${health.total} tills offline`, detail: 'The rest are reporting normally.', actionLabel: 'View tills', href: `${base}/terminals`, }); } if (health.pendingBills > 0) { alerts.push({ id: `bills-${row.branch.locationid}`, severity: health.pendingBills > 10 ? 'critical' : 'attention', branch: name, title: `${health.pendingBills} bill${health.pendingBills === 1 ? '' : 's'} not synced`, detail: 'Taken at the counter and not yet in the books, so revenue reads low.', actionLabel: 'View tills', href: `${base}/terminals`, }); } if (row.pendingRequests > 0) { alerts.push({ id: `requests-${row.branch.locationid}`, severity: 'attention', branch: name, title: `${row.pendingRequests} stock request${row.pendingRequests === 1 ? '' : 's'} waiting`, detail: 'Nothing reaches a shelf until these are decided.', actionLabel: 'Review', href: `${base}/inventory?tab=requests`, }); } if (row.cancelled > 0 && row.onlineOrders > 0 && row.cancelled / row.onlineOrders >= 0.2) { alerts.push({ id: `cancels-${row.branch.locationid}`, severity: 'attention', branch: name, title: `${Math.round((row.cancelled / row.onlineOrders) * 100)}% of orders cancelled`, detail: `${row.cancelled} of ${row.onlineOrders} app orders did not complete.`, actionLabel: 'View sales', href: `${base}/sales`, }); } } const order: Record = { critical: 0, attention: 1, healthy: 2 }; return alerts.sort((a, b) => order[a.severity] - order[b.severity]); }