redesign console page
This commit is contained in:
193
src/features/console/consoleModel.ts
Normal file
193
src/features/console/consoleModel.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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';
|
||||
|
||||
export interface HealthRollup {
|
||||
healthy: number;
|
||||
attention: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
export function rollupHealth(rows: readonly BranchRow[]): HealthRollup {
|
||||
const rollup: HealthRollup = { healthy: 0, attention: 0, offline: 0 };
|
||||
for (const row of rows) {
|
||||
const tone = branchTone(row.health);
|
||||
if (tone === 'critical') rollup.offline += 1;
|
||||
else if (tone === 'attention') rollup.attention += 1;
|
||||
else rollup.healthy += 1;
|
||||
}
|
||||
return rollup;
|
||||
}
|
||||
|
||||
/* ── 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user