177 lines
6.4 KiB
TypeScript
177 lines
6.4 KiB
TypeScript
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<ConsoleTotals>(
|
|
(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<HealthTone, number> = { critical: 0, attention: 1, healthy: 2 };
|
|
return alerts.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
}
|