redesign console page

This commit is contained in:
2026-09-03 12:31:07 +05:30
parent d39956a2f1
commit e1bb0a5307
22 changed files with 2478 additions and 22 deletions

View 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]);
}