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

@@ -34,7 +34,10 @@ const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/ne
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
const ConsolePage = named('ConsolePage', () => import('@/features/store-admin/pages/ConsolePage'));
/* One Console for both workspaces — it reads its own scope from BranchScope,
which pins a store user to their outlet and lets an admin choose. Both routes
below therefore render the same component, not two copies of one board. */
const ConsolePage = named('ConsolePage', () => import('@/features/console/ConsolePage'));
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));

View File

@@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAssistantScope } from './assistantScope';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import {
DEFAULT_WIDTH,
@@ -146,7 +147,17 @@ export function AssistantPanel({
const [isDragging, setDragging] = useState(false);
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
const routeContext = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
/**
* The heading follows the branch in view.
*
* A panel headed "Across your branches" sitting above a board showing one
* shop is a promise about scope that the page has already broken. Only the
* title moves; everything else about Buddy is untouched.
*/
const scopeLabel = useAssistantScope();
const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext;
/**
* Three widths, in priority order: stacked (the phone layout owns it),

View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';
/**
* What Nearle Buddy is currently answering about.
*
* The panel names its own scope in its heading — "Across your branches" — and
* that heading is a promise about which data an answer would draw on. When an
* admin narrows to one branch, or a store user opens the console at all, the
* promise is wrong: the board beneath says R mart and the panel above still
* says every branch.
*
* So the SCOPE is published here and the panel reads it. Nothing else about
* Buddy changes — not its width, its controls, its prompts or its placement.
* A page that has no branch scope publishes nothing and the panel keeps the
* per-route wording it has always used.
*/
export const AssistantScopeContext = createContext<string | undefined>(undefined);
export function useAssistantScope(): string | undefined {
return useContext(AssistantScopeContext);
}

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { AssistantScopeContext } from '@/components/shell/assistantScope';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { consoleScope } from './consoleScope';
/**
* Publishes the branch in view to Nearle Buddy.
*
* It has to sit here — inside `BranchScopeProvider` and OUTSIDE `AppShell` —
* because Buddy is rendered by the shell, which is a parent of the page. React
* context flows down, so a provider inside the Console could never reach the
* panel above it. That was the first attempt, and the panel went on announcing
* "Across your branches" over a board showing one shop.
*
* Renders only its children, so its position in the tree costs nothing.
*/
export function AssistantScope({ children }: { children: ReactNode }) {
const { branches, selected, current, isPinned } = useBranchScope();
const scope = consoleScope({
role: isPinned ? 'store-user' : 'admin',
selected,
branchName: current?.locationname,
branchCount: branches.length,
});
return (
<AssistantScopeContext.Provider value={scope.buddyContext}>
{children}
</AssistantScopeContext.Provider>
);
}

View File

@@ -0,0 +1,206 @@
import { useMemo, useState } from 'react';
import type { DateRange } from '@/api/insights';
import { DateRangePicker, presetRange, type RangePreset } from '@/features/store-admin/DateRangePicker';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { summariseBranch } from '@/features/store-admin/posStatus';
import {
useLocationProducts,
useLocationSummary,
useOwnTenant,
usePosHealthByBranch,
usePosSalesByBranch,
useStockRequests,
} from '@/queries/hooks';
import { buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel';
import { consoleScope } from './consoleScope';
import { previousRange, useConsoleSeries } from './useConsoleSeries';
import { BranchOverview } from './sections/BranchOverview';
import { KpiStrip } from './sections/KpiStrip';
import { NeedsAttention } from './sections/NeedsAttention';
import { SalesOverview } from './sections/SalesOverview';
import { StoreHealth } from './sections/StoreHealth';
import { TillSync } from './sections/TillSync';
import { YourShop } from './sections/YourShop';
import './console.css';
/**
* One Console, three situations.
*
* An admin across every branch, an admin looking at one, and a store user who
* has exactly one. They differ in wording and in which sections appear — never
* in which component renders — so there is no second implementation to keep in
* step. `consoleScope` decides those differences as data; this file lays them
* out.
*
* ── Where the scope comes from ──────────────────────────────────────────────
*
* `useBranchScope` alone. It already pins a store user to their own outlet and
* ignores the URL parameter for them, so this page never asks who the user is
* in order to decide what to fetch — it asks what is in scope and fetches that.
* Re-deriving the scope here would be a second place for the two to disagree.
*
* ── The order of the sections ───────────────────────────────────────────────
*
* How much did we take, how did it arrive and is the shop working, which
* branch, what the counter is doing, what to do now. Money first because it is
* what the page is opened for; the thing to act on last because a merchant
* reads down and should finish on a verb.
*/
export function ConsolePage() {
const { branches, scoped, selected, current, tenantid, isLoading, isPinned, select } =
useBranchScope();
const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const prev = useMemo(() => previousRange(range), [range]);
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
const posNow = usePosSalesByBranch(branchIds, range);
const posBefore = usePosSalesByBranch(branchIds, prev);
const posHealth = usePosHealthByBranch(branchIds);
const shop = useOwnTenant(tenantid || undefined);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
allBranches: true,
});
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
);
const posNowData = useMemo(
() => posNow.flatMap((query) => (query.data ? [query.data] : [])),
[posNow],
);
const posBeforeData = useMemo(
() => posBefore.flatMap((query) => (query.data ? [query.data] : [])),
[posBefore],
);
const series = useConsoleSeries(tenantid, selected, range, posNowData, posBeforeData);
// One instant for the whole board, so two tills read a second apart are not
// judged against two different "nows".
const now = Date.now();
const rows = useMemo<BranchRow[]>(
() =>
scoped.map((branch, index) => {
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
const pos = posNow[index]?.data;
return {
branch,
// `getlocationsummary` carries no date filter, so it is used for the
// per-branch split the table needs. The dated period figures come
// from the order rows — see `totals` below.
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: summariseBranch(posHealth[index]?.data ?? [], now),
pendingRequests: (requests.data ?? []).filter(
(entry) => entry.locationid === branch.locationid,
).length,
};
}),
[scoped, orders.data, posNow, posHealth, requests.data, now],
);
const totals = useMemo(() => {
const summed = totalsOf(rows);
// The dated figures win where they exist: a board headed "Sep 3" must not
// show an all-time online total beside a one-day counter total.
return {
...summed,
onlineRevenue: series.onlineRevenue,
onlineOrders: series.onlineOrders,
cancelled: series.cancelled,
totalRevenue: series.onlineRevenue + summed.counterRevenue,
totalOrders: series.onlineOrders + summed.counterBills,
};
}, [rows, series]);
const rollup = useMemo(() => rollupHealth(rows), [rows]);
const alerts = useMemo(() => buildAlerts(rows, base), [rows, base]);
const scope = consoleScope({
role: isPinned ? 'store-user' : 'admin',
selected,
branchName: current?.locationname,
branchCount: branches.length,
});
const isBusy =
isLoading ||
posNow.some((query) => query.isLoading) ||
posHealth.some((query) => query.isLoading) ||
series.isLoading;
return (
<div className="console">
<header className="console-head">
<div className="console-title">
<div className="console-title-row">
<h1>Console</h1>
<span className="console-scope">{scope.countLabel}</span>
<span className="console-live"><i />Live</span>
</div>
<p className="console-blurb">{scope.blurb}</p>
</div>
<div className="console-actions">
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</div>
</header>
<KpiStrip
totals={totals}
days={series.days}
trends={series.trends}
comparisonLabel={preset === 'today' ? 'yesterday' : 'previous period'}
isLoading={isBusy}
/>
<div className="console-split">
<SalesOverview days={series.days} isLoading={isBusy} />
<StoreHealth
rows={rows}
totals={totals}
rollup={rollup}
isAggregate={scope.isAggregate}
productCount={(products.data ?? []).length}
base={base}
/>
</div>
{scope.showBranchOverview ? (
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Branch performance</h3>
<p className="panel-sub">
Ordered by takings — open one to scope the whole board to it
</p>
</div>
</header>
<BranchOverview rows={rows} onSelect={select} />
</section>
) : (
<YourShop row={rows[0]} shop={shop.data} title={scope.shopSectionTitle} base={base} />
)}
<TillSync rows={rows} showBranch={scope.showBranchColumn} base={base} />
<NeedsAttention alerts={alerts} isAggregate={scope.isAggregate} base={base} />
</div>
);
}

View File

@@ -0,0 +1,346 @@
/* ══ Console ═══════════════════════════════════════════════════════════════
The board a shopkeeper opens twenty times a day.
Kept beside the feature rather than in `index.css`, which had grown to 1,700
lines of everything. Built entirely from the existing tokens: brand purple
carries action and identity, and semantic green / amber / red are reserved
for state — so a colour never means two things on one screen. */
.console { display: flex; flex-direction: column; gap: 18px; }
/* ── Header ─────────────────────────────────────────────────────────────── */
.console-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.console-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.console-title h1 {
margin: 0;
font: 600 26px/1.2 var(--font-display), Georgia, serif;
color: var(--color-ink-1);
letter-spacing: -0.01em;
}
.console-scope { font: 500 14px/1 var(--font-sans); color: var(--color-ink-3); }
.console-live {
display: inline-flex; align-items: center; gap: 6px;
font: 500 12.5px/1 var(--font-sans); color: var(--color-success, #1c6b47);
}
/* A pulse, not a blink: it says "reading" without demanding attention. */
.console-live i {
width: 7px; height: 7px; border-radius: 999px; background: currentColor;
animation: live-pulse 2s ease-in-out infinite;
}
@keyframes live-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.console-blurb { margin: 6px 0 0; font: 400 13.5px/1.5 var(--font-sans); color: var(--color-ink-3); }
.console-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.health-pill {
display: inline-flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: 12px; text-decoration: none;
border: 1px solid var(--color-line); background: var(--color-surface);
font: 600 13px/1.3 var(--font-sans);
}
.health-pill span { display: flex; flex-direction: column; line-height: 1.25; }
.health-pill b { font: 500 11px/1.3 var(--font-sans); color: var(--color-ink-3); }
.health-pill[data-tone="healthy"] { border-color: #bfe3cf; background: #eef8f2; color: #1c6b47; }
.health-pill[data-tone="attention"] { border-color: #ecd9a8; background: #fdf6e8; color: #8a5a00; }
.health-pill[data-tone="critical"] { border-color: #f0c4c0; background: #fdefee; color: #b3261e; }
.health-pill[data-tone="healthy"] b,
.health-pill[data-tone="attention"] b,
.health-pill[data-tone="critical"] b { color: inherit; opacity: .75; }
/* ── KPI cards ──────────────────────────────────────────────────────────── */
.kpi-strip {
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(238px, 1fr);
gap: 14px; overflow-x: auto; padding-bottom: 4px; scroll-snap-type: x proximity;
}
.kpi-strip > * { scroll-snap-align: start; }
@media (min-width: 1180px) {
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
}
.kpi {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
.kpi-top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; }
.kpi-icon {
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
background: var(--color-brand-tint); color: var(--color-brand);
}
.kpi-head { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
has stopped doing its job. The min-height keeps all five value rows level
whether the label took one line or two. */
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
.kpi-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.spark { width: 56px; height: 20px; flex: none; opacity: .9; }
.kpi-trend { display: flex; align-items: center; gap: 5px; font: 500 12.5px/1 var(--font-sans); }
.kpi-trend[data-good="yes"] { color: #1c6b47; }
.kpi-trend[data-good="no"] { color: #b3261e; }
.kpi-trend[data-good="neutral"] { color: var(--color-ink-3); }
.kpi-vs { color: var(--color-ink-3); font-weight: 400; }
/* ── Panels ─────────────────────────────────────────────────────────────── */
.panel {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 18px;
display: flex; flex-direction: column; gap: 14px; min-width: 0;
}
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); }
.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
.panel-foot {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3);
}
.panel-empty {
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
border: 1px dashed var(--color-line); border-radius: 12px;
}
.panel-empty strong { font: 600 14px/1.3 var(--font-sans); color: var(--color-ink-1); }
.panel-empty span { font: 400 13px/1.5 var(--font-sans); color: var(--color-ink-3); }
/* The chart needs width to be a trend; health is a list and does not. */
.console-split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 1080px) { .console-split { grid-template-columns: minmax(0, 1.32fr) minmax(0, 1fr); } }
/* ── Shared: tiles, pills, buttons ──────────────────────────────────────── */
.tile {
width: 34px; height: 34px; flex: none; border-radius: 10px; display: grid; place-items: center;
background: var(--color-surface-subtle); color: var(--color-ink-2);
}
.tile[data-tone="brand"] { background: var(--color-brand-tint); color: var(--color-brand); }
.tile[data-tone="healthy"] { background: #eef8f2; color: #1c6b47; }
.tile[data-tone="attention"] { background: #fdf6e8; color: #8a5a00; }
.tile[data-tone="critical"] { background: #fdefee; color: #b3261e; }
.pill {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
border-radius: 999px; white-space: nowrap; font: 500 11.5px/1 var(--font-sans);
border: 1px solid transparent;
}
.pill i { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
.pill[data-tone="healthy"] { background: #eef8f2; color: #1c6b47; border-color: #cbe8d8; }
.pill[data-tone="attention"] { background: #fdf6e8; color: #8a5a00; border-color: #eeddb4; }
.pill[data-tone="critical"] { background: #fdefee; color: #b3261e; border-color: #f2cbc7; }
.btn-outline, .btn-solid {
display: inline-flex; align-items: center; justify-content: center;
height: 30px; padding: 0 12px; border-radius: 9px; white-space: nowrap;
font: 500 12.5px/1 var(--font-sans); text-decoration: none;
border: 1px solid var(--color-line); background: var(--color-surface); color: var(--color-ink-1);
}
.btn-outline:hover { border-color: var(--color-brand); color: var(--color-brand); }
.btn-solid { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
.btn-solid[data-tone="critical"] { background: #b3261e; border-color: #b3261e; }
.btn-solid[data-tone="attention"] { background: #8a5a00; border-color: #8a5a00; }
.btn-outline:focus-visible, .btn-solid:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.link-quiet { font: 500 12.5px/1 var(--font-sans); color: var(--color-brand); text-decoration: none; }
.link-quiet:hover { text-decoration: underline; }
/* ── Sales overview ─────────────────────────────────────────────────────── */
.sales-split { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 720px) { .sales-split { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
.chart-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; }
.chart-axis {
display: flex; flex-direction: column; justify-content: space-between; height: 190px;
text-align: right; font: 400 10.5px/1 var(--font-sans); color: var(--color-ink-4);
font-variant-numeric: tabular-nums;
}
.chart2 {
display: flex; align-items: flex-end; gap: 10px; height: 190px; overflow-x: auto;
border-left: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); padding: 0 6px;
}
.chart2-col { flex: 1 0 34px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; }
.chart2-bars { display: flex; align-items: flex-end; gap: 3px; height: 100%; width: 100%; justify-content: center; }
.chart2-bar { width: 11px; border-radius: 4px 4px 0 0; min-height: 2px; transition: opacity .15s ease; }
.chart2-bar[data-kind="online"] { background: var(--color-brand); }
/* The second channel is the same hue at lower weight, so the pair reads as one
family rather than two unrelated categories. */
.chart2-bar[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.chart2-col:hover .chart2-bar { opacity: .78; }
.chart2-label { font: 500 10.5px/1 var(--font-sans); color: var(--color-ink-4); white-space: nowrap; }
.chart-legend { grid-column: 2; display: flex; gap: 16px; padding-top: 6px; }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; font: 400 12px/1 var(--font-sans); color: var(--color-ink-3); }
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind="online"] { background: var(--color-brand); }
.chart-legend i[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.donut-wrap { display: flex; flex-direction: column; align-items: center; gap: 16px; justify-content: center; }
.donut { position: relative; width: 160px; height: 160px; }
.donut svg { width: 100%; height: 100%; }
.donut-track { fill: none; stroke: var(--color-surface-subtle); stroke-width: 18; }
.donut-fill { fill: none; stroke: var(--color-brand); stroke-width: 18; transition: stroke-dasharray .4s ease; }
.donut-centre { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 2px; }
.donut-centre span { font: 400 11px/1 var(--font-sans); color: var(--color-ink-3); }
.donut-centre strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
.donut-legend { display: flex; flex-direction: column; gap: 12px; width: 100%; }
.donut-legend > div { display: flex; gap: 10px; align-items: flex-start; }
.donut-legend > div > div { display: flex; flex-direction: column; gap: 1px; }
.donut-key { width: 10px; height: 10px; border-radius: 999px; margin-top: 4px; flex: none; }
.donut-key[data-kind="online"] { background: var(--color-brand); }
.donut-key[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.donut-label { font: 400 12.5px/1.2 var(--font-sans); color: var(--color-ink-3); }
.donut-pct { font: 600 16px/1.2 var(--font-sans); color: var(--color-ink-1); }
.donut-amt { font: 500 12.5px/1.2 var(--font-sans); color: var(--color-brand); font-variant-numeric: tabular-nums; }
/* ── Store health ───────────────────────────────────────────────────────── */
.health-list { display: flex; flex-direction: column; }
.health-line {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 12px; align-items: center; padding: 11px 2px;
border-bottom: 1px solid var(--color-line);
}
.health-list > .health-line:last-child { border-bottom: none; }
.health-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.health-name { font: 600 13.5px/1.2 var(--font-sans); color: var(--color-ink-1); }
.health-detail { font: 400 12px/1.4 var(--font-sans); color: var(--color-ink-3); }
.health-rollup { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.rollup-chip {
padding: 12px 14px; border: 1px solid var(--color-line); border-left-width: 3px;
border-radius: 12px; background: var(--color-surface);
display: flex; flex-direction: column; gap: 2px;
}
.rollup-chip strong { font: 600 20px/1 var(--font-sans); font-variant-numeric: tabular-nums; }
.rollup-chip span { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
.rollup-chip[data-tone="healthy"] { border-left-color: #1c6b47; }
.rollup-chip[data-tone="attention"] { border-left-color: #8a5a00; }
.rollup-chip[data-tone="critical"] { border-left-color: #b3261e; }
/* ── Your shop ──────────────────────────────────────────────────────────── */
/* Two panels, not three. The shop and its takings are one statement; the
actions are the other. Three columns each doing a third of a job left every
card half empty and stretched to the tallest of them. */
.shop-row { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 900px) { .shop-row { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); } }
.shop-id { display: flex; gap: 14px; align-items: center; }
.shop-idtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.shop-title { font: 600 17px/1.2 var(--font-sans); color: var(--color-ink-1); }
.shop-mark { width: 54px; height: 54px; flex: none; border-radius: 14px; display: grid; place-items: center; background: var(--color-brand-tint); color: var(--color-brand); }
.shop-meta { margin: 0; display: flex; gap: 7px; align-items: flex-start; font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); }
.shop-meta svg { flex: none; margin-top: 2px; }
.perf-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.perf { padding: 12px 14px; border: 1px solid var(--color-line); border-radius: 12px; display: flex; flex-direction: column; gap: 3px; }
.perf[data-strong="yes"] { background: var(--color-brand-tint); border-color: var(--color-brand); }
.perf[data-strong="yes"] strong { color: var(--color-brand); }
.perf-label { display: inline-flex; align-items: center; gap: 6px; font: 400 11.5px/1 var(--font-sans); color: var(--color-ink-3); }
.perf strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
.perf-note { font: 400 11.5px/1 var(--font-sans); color: var(--color-ink-4); }
.quick-list { display: flex; flex-direction: column; gap: 8px; }
.quick {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px;
text-decoration: none; background: var(--color-surface);
}
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
.quick:hover { border-color: var(--color-brand); background: var(--color-brand-tint); }
.quick-label { font: 500 13px/1.2 var(--font-sans); color: var(--color-ink-1); }
.quick-chev { color: var(--color-ink-4); }
/* ── Tables ─────────────────────────────────────────────────────────────── */
.grid-table, .branch-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 680px; }
.grid-table th, .grid-table td, .branch-table th, .branch-table td {
text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--color-line); white-space: nowrap;
}
.grid-table th, .branch-table th {
font: 500 10.5px/1 var(--font-sans); letter-spacing: .07em; text-transform: uppercase;
color: var(--color-ink-3); background: var(--color-surface-subtle);
}
.grid-table tbody tr:last-child td, .branch-table tbody tr:last-child td { border-bottom: none; }
.grid-table .num, .branch-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.grid-table tbody tr:hover, .branch-table tbody tr:hover { background: var(--color-surface-subtle); }
.heartbeat { display: inline-flex; align-items: center; gap: 7px; color: var(--color-ink-2); }
.heartbeat i { width: 6px; height: 6px; border-radius: 999px; }
.heartbeat[data-tone="healthy"] i { background: #1c6b47; }
.heartbeat[data-tone="attention"] i { background: #8a5a00; }
.heartbeat[data-tone="critical"] i { background: #b3261e; }
.till-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.chip-row { display: inline-flex; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: 999px; cursor: pointer; border: 1px solid var(--color-line);
background: var(--color-surface); font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2);
}
.chip b { font-weight: 600; opacity: .7; }
.chip[data-active="true"] { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
.chip[data-tone="attention"][data-active="true"] { background: #8a5a00; border-color: #8a5a00; }
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.search {
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px;
min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px;
background: var(--color-surface); color: var(--color-ink-4);
}
.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); }
.search:focus-within { border-color: var(--color-brand); }
/* ── Segmented control ──────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
.seg-btn { border: 0; background: none; padding: 6px 14px; border-radius: 8px; font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2); cursor: pointer; }
.seg-btn[data-active="yes"] { background: var(--color-brand); color: #fff; }
.seg-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* ── Needs your attention ───────────────────────────────────────────────── */
.attention-head { display: flex; gap: 12px; align-items: flex-start; }
.attention-clear { display: flex; gap: 12px; align-items: center; }
.attention-clear div { display: flex; flex-direction: column; gap: 2px; }
.attention-clear strong { font: 600 14px/1.2 var(--font-sans); color: var(--color-ink-1); }
.attention-clear span { font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
.attention-grid { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 860px) { .attention-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.attention-card {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px;
}
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
.attention-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.attention-title { font: 400 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
.attention-detail { font: 400 12px/1.45 var(--font-sans); color: var(--color-ink-3); }
.sev { padding: 3px 9px; border-radius: 999px; font: 500 11px/1 var(--font-sans); white-space: nowrap; }
.sev[data-tone="critical"] { background: #fadad6; color: #b3261e; }
.sev[data-tone="attention"] { background: #f8e9c8; color: #8a5a00; }
/* The action drops beneath rather than squeezing the text to two words. */
@media (max-width: 700px) {
.attention-card, .health-line { grid-template-columns: auto minmax(0, 1fr); }
.attention-card > :nth-child(3), .attention-card > :nth-child(4),
.health-line > :nth-child(3), .health-line > :nth-child(4) { grid-column: 2; justify-self: start; }
}
/* ── Loading ────────────────────────────────────────────────────────────── */
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
.sk-card, .sk-chart {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
}
.sk-card { height: 104px; }
.sk-chart { height: 300px; }
@media (prefers-reduced-motion: reduce) {
.sk-card, .sk-chart, .console-live i { animation: none; }
.chart2-bar, .donut-fill { transition: none; }
}

View File

@@ -0,0 +1,129 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { TenantLocation } from '@/api/types';
import type { BranchSyncSummary, TerminalState } from '@/features/store-admin/posStatus';
import { branchTone, buildAlerts, rollupHealth, totalsOf, type BranchRow } from './consoleModel';
const health = (over: Partial<BranchSyncSummary> = {}): BranchSyncSummary => ({
state: 'synced' as TerminalState,
online: 2,
total: 2,
pendingBills: 0,
oldestPendingMs: 0,
lastBillAt: null,
terminals: [],
...over,
});
const row = (id: number, over: Partial<BranchRow> = {}): BranchRow => ({
branch: { locationid: id, locationname: `Branch ${id}` } as TenantLocation,
onlineRevenue: 0, onlineOrders: 0, cancelled: 0, delivered: 0,
counterRevenue: 0, counterBills: 0, pendingRequests: 0,
health: health(),
...over,
});
/* ── Totals ───────────────────────────────────────────────────────────────── */
test('the aggregate is the sum of exactly the branches in scope', () => {
// The honesty of "All branches" rests on this: the figure shown is the sum of
// the rows listed under it, not a separate query with its own idea of total.
const totals = totalsOf([
row(1, { onlineRevenue: 100, counterRevenue: 50, onlineOrders: 4, counterBills: 3 }),
row(2, { onlineRevenue: 200, counterRevenue: 25, onlineOrders: 6, counterBills: 1 }),
]);
assert.equal(totals.onlineRevenue, 300);
assert.equal(totals.counterRevenue, 75);
assert.equal(totals.totalRevenue, 375);
assert.equal(totals.totalOrders, 14);
});
test('one branch in scope totals to that branch', () => {
const totals = totalsOf([row(1, { onlineRevenue: 100, counterRevenue: 50 })]);
assert.equal(totals.totalRevenue, 150);
});
test('no branches is zero, not a crash or a blank', () => {
const totals = totalsOf([]);
assert.equal(totals.totalRevenue, 0);
assert.equal(totals.tillsTotal, 0);
});
/* ── Health ───────────────────────────────────────────────────────────────── */
test('every till down is critical, not merely attention', () => {
assert.equal(branchTone(health({ online: 0, total: 3 })), 'critical');
});
test('some tills down is attention', () => {
assert.equal(branchTone(health({ online: 2, total: 3 })), 'attention');
});
test('a stale till outranks a delayed one', () => {
// Its last word was "fine" and everything since is guesswork.
assert.equal(branchTone(health({ state: 'stale' })), 'critical');
assert.equal(branchTone(health({ state: 'delayed' })), 'attention');
});
test('a branch with no tills is not reported as healthy', () => {
// Silence is not health — nothing is being counted at that counter.
assert.equal(branchTone(health({ online: 0, total: 0 })), 'attention');
});
test('the rollup counts every branch exactly once', () => {
const rollup = rollupHealth([
row(1),
row(2, { health: health({ online: 1, total: 3 }) }),
row(3, { health: health({ online: 0, total: 2 }) }),
]);
assert.deepEqual(rollup, { healthy: 1, attention: 1, offline: 1 });
});
/* ── Alerts ───────────────────────────────────────────────────────────────── */
test('a healthy shop raises nothing at all', () => {
// The empty state is a real outcome, not a gap to fill with reassurance.
assert.deepEqual(buildAlerts([row(1)], '/admin'), []);
});
test('the worst thing is first', () => {
const alerts = buildAlerts(
[row(1, { pendingRequests: 2 }), row(2, { health: health({ online: 0, total: 2 }) })],
'/admin',
);
assert.equal(alerts[0]?.severity, 'critical');
});
test('every alert names its branch, even on a single-branch board', () => {
// An alert someone screenshots and forwards should say where it came from.
for (const alert of buildAlerts([row(7, { pendingRequests: 1 })], '/store')) {
assert.equal(alert.branch, 'Branch 7');
}
});
test('unsynced bills say why they matter, not just that they exist', () => {
const alert = buildAlerts([row(1, { health: health({ pendingBills: 3 }) })], '/admin')[0];
assert.match(String(alert?.detail), /revenue reads low/);
});
test('a large unsynced queue is escalated', () => {
const small = buildAlerts([row(1, { health: health({ pendingBills: 3 }) })], '/admin')[0];
const large = buildAlerts([row(1, { health: health({ pendingBills: 40 }) })], '/admin')[0];
assert.equal(small?.severity, 'attention');
assert.equal(large?.severity, 'critical');
});
test('cancellations are only raised when they are a pattern', () => {
// One cancelled order in fifty is a shopper changing their mind.
const rare = buildAlerts([row(1, { onlineOrders: 50, cancelled: 1 })], '/admin');
assert.equal(rare.length, 0);
const heavy = buildAlerts([row(1, { onlineOrders: 10, cancelled: 3 })], '/admin');
assert.equal(heavy.length, 1);
});
test('alerts link into the workspace the reader is already in', () => {
// A store user must not be sent to /admin by an alert.
for (const alert of buildAlerts([row(1, { pendingRequests: 1 })], '/store')) {
assert.ok(alert.href.startsWith('/store'), alert.href);
}
});

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

View File

@@ -0,0 +1,67 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { consoleScope } from './consoleScope';
/*
The rules a later refactor is most likely to break are the ones about what a
store user may see. They are asserted here rather than left to be noticed.
*/
const admin = { role: 'admin' as const, branchCount: 13 };
const storeUser = { role: 'store-user' as const, branchCount: 1 };
test('an admin across branches is told the numbers are aggregated', () => {
const scope = consoleScope({ ...admin, selected: null });
assert.equal(scope.countLabel, '13 branches');
assert.equal(scope.isAggregate, true);
assert.match(scope.blurb, /across your branches/);
assert.equal(scope.buddyContext, 'Across your branches');
});
test('one branch is never described as many', () => {
const scope = consoleScope({ role: 'admin', branchCount: 1, selected: null });
assert.equal(scope.countLabel, '1 branch');
});
test('choosing a branch narrows the header, the blurb and Buddy together', () => {
const scope = consoleScope({ ...admin, selected: 1185, branchName: 'R mart' });
assert.equal(scope.countLabel, 'R mart');
assert.equal(scope.blurb, 'Everything trading right now — for R mart.');
assert.equal(scope.buddyContext, 'R mart');
assert.equal(scope.isAggregate, false);
});
test('a store user never aggregates, even if the selection arrives null', () => {
// The frontend pins them, but a pin that fails open is the whole risk. This
// asserts the fallback is "your own store", never "everything".
const scope = consoleScope({ ...storeUser, selected: null });
assert.equal(scope.isAggregate, false);
assert.equal(scope.showBranchOverview, false);
assert.equal(scope.buddyContext, 'your store');
});
test('a store user is never offered the branch overview', () => {
for (const selected of [null, 1185]) {
const scope = consoleScope({ ...storeUser, selected, branchName: 'R mart' });
assert.equal(scope.showBranchOverview, false, String(selected));
}
});
test('the shop section is not called "your shop" when many are in view', () => {
assert.equal(consoleScope({ ...admin, selected: null }).shopSectionTitle, 'Branch performance');
assert.equal(
consoleScope({ ...storeUser, selected: 1185, branchName: 'R mart' }).shopSectionTitle,
'Your shop',
);
});
test('a branch column only earns its width when branches are mixed', () => {
assert.equal(consoleScope({ ...admin, selected: null }).showBranchColumn, true);
assert.equal(consoleScope({ ...admin, selected: 1185 }).showBranchColumn, false);
assert.equal(consoleScope({ ...storeUser, selected: 1185 }).showBranchColumn, false);
});
test('a blank branch name does not produce a sentence with a hole in it', () => {
const scope = consoleScope({ ...admin, selected: 1185, branchName: ' ' });
assert.equal(scope.blurb, 'Everything trading right now — for your store.');
});

View File

@@ -0,0 +1,82 @@
/**
* What this Console is showing, and to whom.
*
* One component serves three situations — an admin across every branch, an
* admin looking at one, and a store user who has exactly one. The differences
* are almost entirely in the words and in which sections appear, so they are
* decided here as data rather than scattered through the layout as `isPinned &&`
* conditions that drift apart.
*
* Pure on purpose. The rules about what a store user may see are the kind that
* get quietly broken by a later refactor, so they are tested rather than
* inspected.
*/
export type ConsoleRole = 'admin' | 'store-user';
export interface ConsoleScopeInput {
role: ConsoleRole;
/** The branch in view. `null` means every branch the user may see. */
selected: number | null;
/** Branch name when one is selected. */
branchName?: string | undefined;
/** How many branches this user is authorised for. */
branchCount: number;
}
export interface ConsoleScope {
/** True only for an admin looking across more than their own single branch. */
isAggregate: boolean;
/** Sits beside the "Console" title. */
countLabel: string;
/** The sentence under the title. */
blurb: string;
/** What Nearle Buddy says it is answering about. */
buddyContext: string;
/** Admin-only, and only across branches. */
showBranchOverview: boolean;
/** "Your shop" reads as a lie when several are in view. */
shopSectionTitle: string;
/** Whether a per-branch column earns its width. */
showBranchColumn: boolean;
}
export function consoleScope({
role,
selected,
branchName,
branchCount,
}: ConsoleScopeInput): ConsoleScope {
// A store user is pinned to one outlet, so "all branches" is not a state they
// can reach — and if a stray null ever arrives here it must not be read as
// permission to aggregate.
const isAggregate = role === 'admin' && selected === null;
if (isAggregate) {
return {
isAggregate: true,
countLabel: `${branchCount} ${branchCount === 1 ? 'branch' : 'branches'}`,
blurb: 'Everything trading right now — across your branches, and what needs your attention.',
buddyContext: 'Across your branches',
showBranchOverview: true,
// Naming it "Your shop" while thirteen are in view invites a merchant to
// read one shop's numbers as the whole business.
shopSectionTitle: 'Branch performance',
showBranchColumn: true,
};
}
const name = branchName?.trim() || 'your store';
return {
isAggregate: false,
countLabel: name,
blurb: `Everything trading right now — for ${name}.`,
buddyContext: name,
showBranchOverview: false,
shopSectionTitle: role === 'store-user' ? 'Your shop' : 'Branch',
// A store user already knows which branch they are in; an admin who chose
// one branch does too. Neither needs it repeated in every table row.
showBranchColumn: false,
};
}

View File

@@ -0,0 +1,105 @@
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 { ArrowRight } from 'lucide-react';
import { branchLabel, count, money } from '@/features/store-admin/format';
import { branchTone, type BranchRow } from '../consoleModel';
/**
* Which branches are doing well, and which want looking at.
*
* Admin-only and aggregate-only: a store user has one branch and an admin who
* has already chosen one is looking at it, so in both cases this is a table of
* one row restating the page around it.
*
* Sorted by takings, worst health first within that — the two questions a
* multi-branch owner actually opens this for are "who is selling" and "who is
* broken", and one ordering can serve both if health is on the row.
*
* "View branch" sets the branch selector rather than navigating somewhere new,
* so the whole Console becomes that branch's — which is the same thing the top
* navigation does, reached from the place the question was asked.
*/
export interface BranchOverviewProps {
rows: readonly BranchRow[];
onSelect: (locationid: number) => void;
}
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
if (rows.length === 0) {
return (
<Card padding={3} elevation="low">
<Text type="body" size="sm" color="secondary">
No branches are set up yet.
</Text>
</Card>
);
}
const ordered = [...rows].sort(
(a, b) => b.onlineRevenue + b.counterRevenue - (a.onlineRevenue + a.counterRevenue),
);
return (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="branch-table">
<thead>
<tr>
<th>Branch</th>
<th className="num">Online</th>
<th className="num">Counter</th>
<th className="num">Total</th>
<th className="num">Orders</th>
<th>Tills</th>
<th>Health</th>
<th />
</tr>
</thead>
<tbody>
{ordered.map((row) => {
const tone = branchTone(row.health);
const total = row.onlineRevenue + row.counterRevenue;
return (
<tr key={row.branch.locationid}>
<td>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{branchLabel(row.branch.locationname) || `Branch ${row.branch.locationid}`}
</Text>
</td>
<td className="num">{money(row.onlineRevenue)}</td>
<td className="num">{money(row.counterRevenue)}</td>
<td className="num"><strong>{money(total)}</strong></td>
<td className="num">{count(row.onlineOrders + row.counterBills)}</td>
<td>
{row.health.total === 0
? '—'
: `${row.health.online}/${row.health.total}`}
</td>
<td>
<HStack gap={1} align="center">
<span className="status-dot" data-tone={tone} aria-hidden />
<Text type="body" size="xsm" color="secondary">
{tone === 'healthy' ? 'Healthy' : tone === 'attention' ? 'Attention' : 'Offline'}
</Text>
</HStack>
</td>
<td>
<Button
label="View"
variant="ghost"
size="sm"
endContent={<ArrowRight size={13} />}
onClick={() => onSelect(row.branch.locationid)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,171 @@
import type { ReactNode } from 'react';
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart, TrendingDown, TrendingUp } from 'lucide-react';
import { count, money } from '@/features/store-admin/format';
import type { ConsoleTotals } from '../consoleModel';
import type { DaySeries, Trend } from '../useConsoleSeries';
/**
* The five figures the board opens with.
*
* Each card carries the number, the shape of how it got there, and whether that
* is better or worse than the period before. The sparkline is not decoration:
* ₹12,480 taken evenly across a week and ₹12,480 taken in one afternoon are the
* same number and completely different news, and the line is the only part of
* the card that can tell them apart.
*
* Trend arrows are drawn only where a real comparison exists. `Trend.isNew`
* marks the case where the previous period was zero — "↑ ∞%" is not a fact, so
* it reads "new" instead.
*/
export interface KpiStripProps {
totals: ConsoleTotals;
days: readonly DaySeries[];
trends: {
online: Trend;
counter: Trend;
orders: Trend;
cancelled: Trend;
};
comparisonLabel: string;
isLoading: boolean;
}
export function KpiStrip({ totals, days, trends, comparisonLabel, isLoading }: KpiStripProps) {
if (isLoading) {
return (
<div className="kpi-strip" aria-busy="true">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="sk-card" />
))}
</div>
);
}
return (
<div className="kpi-strip">
<Kpi
icon={<ShoppingCart size={17} />}
label="Online Sales"
value={money(totals.onlineRevenue)}
trend={trends.online}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.online)}
/>
<Kpi
icon={<Receipt size={17} />}
label="Counter Sales"
value={money(totals.counterRevenue)}
trend={trends.counter}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.counter)}
/>
<Kpi
icon={<ListChecks size={17} />}
label="Total Orders"
value={count(totals.totalOrders)}
trend={trends.orders}
comparisonLabel={comparisonLabel}
series={days.map((d) => d.onlineOrders + d.counterBills)}
/>
<Kpi
icon={<Ban size={17} />}
label="Cancelled Orders"
value={count(totals.cancelled)}
trend={trends.cancelled}
comparisonLabel={comparisonLabel}
series={days.map(() => 0)}
// The only card where a rise is bad news, so the colours invert.
isInverted
/>
<Kpi
icon={<CloudUpload size={17} />}
label="Unsynced Bills"
value={count(totals.pendingBills)}
// A live queue depth, not a period figure: there is nothing last week
// to compare it against, so it states what it is instead.
note={totals.pendingBills === 0 ? 'all bills are in the books' : 'waiting on a till'}
series={days.map(() => 0)}
isInverted
/>
</div>
);
}
function Kpi({
icon, label, value, trend, comparisonLabel, series, note, isInverted,
}: {
icon: ReactNode;
label: string;
value: string;
trend?: Trend;
comparisonLabel?: string;
series: readonly number[];
note?: string;
isInverted?: boolean;
}) {
// Up is green unless a rise is the bad outcome — cancellations, unsent bills.
const isGood = trend ? (isInverted ? trend.direction === 'down' : trend.direction === 'up') : true;
// A flat line on an empty period is not bad news, so it is not drawn in red.
const hasShape = series.some((value) => value > 0);
return (
<article className="kpi">
{/* Icon and figure only. The sparkline sits on the row below rather than
beside the label: at the widths this strip actually gets — five cards
sharing the page with Nearle Buddy — a 52px chart alongside truncates
"Cancelled Orders" to "Cance…", and a label that has stopped naming
its number is worse than no chart. */}
<div className="kpi-top">
<span className="kpi-icon" aria-hidden>{icon}</span>
<div className="kpi-head">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
</div>
</div>
<div className="kpi-foot">
{trend && !trend.isNew && trend.percent !== undefined ? (
<div className="kpi-trend" data-good={isGood ? 'yes' : 'no'}>
{trend.direction === 'down' ? <TrendingDown size={13} /> : <TrendingUp size={13} />}
<strong>{trend.percent}%</strong>
<span className="kpi-vs">vs. {comparisonLabel}</span>
</div>
) : (
<div className="kpi-trend" data-good="neutral">
<span className="kpi-vs">{note ?? (trend?.isNew ? 'new this period' : `vs. ${comparisonLabel}`)}</span>
</div>
)}
<Sparkline values={series} tone={!hasShape ? 'none' : isGood ? 'good' : 'bad'} />
</div>
</article>
);
}
/**
* The shape of the period, in about forty pixels.
*
* A polyline over a normalised series. Flat or empty data draws a flat line
* rather than nothing, because an absent sparkline reads as a broken card while
* a flat one correctly says "nothing moved".
*/
function Sparkline({ values, tone }: { values: readonly number[]; tone: 'good' | 'bad' | 'none' }) {
const points = values.length >= 2 ? values : [0, 0];
const peak = Math.max(...points);
const floor = Math.min(...points);
const span = peak - floor || 1;
const path = points
.map((value, i) => {
const x = (i / (points.length - 1)) * 60;
const y = 20 - ((value - floor) / span) * 18;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
return (
<svg className="spark" viewBox="0 0 60 22" aria-hidden focusable="false">
<path d={path} fill="none" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
stroke={tone === 'none' ? 'var(--color-line)' : tone === 'bad' ? '#b3261e' : 'var(--color-brand)'} />
</svg>
);
}

View File

@@ -0,0 +1,86 @@
import { Link } from 'react-router-dom';
import { Bell, CheckCircle2 } from 'lucide-react';
import type { ConsoleAlert } from '../consoleModel';
/**
* The one section that exists to be acted on.
*
* Two to a row rather than a stack, so the whole list is visible without
* scrolling past it — an alert below the fold is an alert nobody acts on. Each
* card carries severity, what happened, why it matters, and the verb.
*
* Capped, and the cap is the point: thirteen branches with three problems each
* is thirty-nine cards, which is a list nobody reads to the end of. The worst
* are shown and the rest are counted, so "View all" is a real destination
* rather than a way of hiding the number.
*/
const SHOWN = 4;
export interface NeedsAttentionProps {
alerts: readonly ConsoleAlert[];
isAggregate: boolean;
base: string;
}
export function NeedsAttention({ alerts, isAggregate, base }: NeedsAttentionProps) {
if (alerts.length === 0) {
return (
<section className="panel attention-panel">
<div className="attention-clear">
<span className="tile" data-tone="healthy" aria-hidden><CheckCircle2 size={16} /></span>
<div>
<strong>You&rsquo;re all caught up</strong>
<span>
{isAggregate
? 'No branch is reporting anything that needs you right now.'
: 'Nothing needs your attention right now.'}
</span>
</div>
</div>
</section>
);
}
const shown = alerts.slice(0, SHOWN);
return (
<section className="panel attention-panel">
<header className="panel-head">
<div className="attention-head">
<span className="tile" data-tone="brand" aria-hidden><Bell size={16} /></span>
<div>
<h3 className="panel-title">Needs your attention</h3>
<p className="panel-sub">
These items need your action to keep your store running smoothly.
</p>
</div>
</div>
{alerts.length > SHOWN ? (
<Link to={`${base}/terminals`} className="link-quiet">
View all ({alerts.length})
</Link>
) : null}
</header>
<div className="attention-grid">
{shown.map((alert) => (
<article key={alert.id} className="attention-card" data-tone={alert.severity}>
<span className="tile" data-tone={alert.severity} aria-hidden><Bell size={15} /></span>
<div className="attention-text">
<span className="attention-title">
{alert.branch} &mdash; <strong>{alert.title}</strong>
</span>
<span className="attention-detail">{alert.detail}</span>
</div>
<span className="sev" data-tone={alert.severity}>
{alert.severity === 'critical' ? 'High' : 'Medium'}
</span>
<Link to={alert.href} className="btn-solid" data-tone={alert.severity}>
{alert.actionLabel}
</Link>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,193 @@
import { useMemo, useState } from 'react';
import { money, count } from '@/features/store-admin/format';
import type { DaySeries } from '../useConsoleSeries';
/**
* Revenue and orders over the period: the shape on the left, the split on the
* right.
*
* Two views of one dataset, deliberately. The columns answer "when did it
* happen"; the ring answers "where did it come from". Either alone leaves a
* shopkeeper doing arithmetic in their head — a good week of counter sales and
* a collapse in app orders look identical in a single total.
*
* Drawn by hand rather than with a charting library. It is a few dozen columns
* and one ring; a library would be more code shipped than the chart it draws,
* and this way the bars use the same brand token as everything else on the page
* instead of a palette of its own.
*/
export interface SalesOverviewProps {
days: readonly DaySeries[];
isLoading: boolean;
}
type Measure = 'revenue' | 'orders';
export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
const [measure, setMeasure] = useState<Measure>('revenue');
const series = useMemo(
() =>
days.map((d) => ({
day: d.day,
online: measure === 'revenue' ? d.online : d.onlineOrders,
counter: measure === 'revenue' ? d.counter : d.counterBills,
})),
[days, measure],
);
const peak = useMemo(
() => Math.max(1, ...series.map((d) => Math.max(d.online, d.counter))),
[series],
);
const online = series.reduce((sum, d) => sum + d.online, 0);
const counter = series.reduce((sum, d) => sum + d.counter, 0);
const total = online + counter;
const onlineShare = total > 0 ? Math.round((online / total) * 100) : 0;
const format = (value: number) => (measure === 'revenue' ? money(value) : count(value));
if (isLoading) return <div className="sk-chart" aria-busy="true" />;
return (
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Sales overview</h3>
<p className="panel-sub">
{days.length === 0
? 'Revenue and orders for the selected period'
: days.length === 1
? 'Revenue and orders for the day'
: `Revenue and orders for the last ${days.length} days`}
</p>
</div>
<div className="seg" role="tablist" aria-label="Measure">
{(['revenue', 'orders'] as const).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={measure === m}
className="seg-btn"
data-active={measure === m ? 'yes' : 'no'}
onClick={() => setMeasure(m)}
>
{m === 'revenue' ? 'Revenue' : 'Orders'}
</button>
))}
</div>
</header>
{total === 0 ? (
<div className="panel-empty">
<strong>No sales in this period</strong>
<span>Pick a wider date range, or check back once the shop has taken an order.</span>
</div>
) : (
<div className="sales-split">
<div className="chart-wrap">
{/* The axis is drawn from the data rather than fixed, so a shop
taking ₹800 a day gets a readable scale instead of a flat line
along the bottom of a ₹20K grid. */}
<div className="chart-axis" aria-hidden>
{[1, 0.75, 0.5, 0.25, 0].map((step) => (
<span key={step}>{format(Math.round(peak * step))}</span>
))}
</div>
<div className="chart2">
{series.map((d) => (
<div key={d.day} className="chart2-col">
<div className="chart2-bars">
<span
className="chart2-bar"
data-kind="online"
style={{ height: `${(d.online / peak) * 100}%` }}
title={`${shortDay(d.day)} · Online ${format(d.online)}`}
/>
<span
className="chart2-bar"
data-kind="counter"
style={{ height: `${(d.counter / peak) * 100}%` }}
title={`${shortDay(d.day)} · Counter ${format(d.counter)}`}
/>
</div>
<span className="chart2-label">{shortDay(d.day)}</span>
</div>
))}
</div>
<div className="chart-legend">
<span><i data-kind="online" />Online</span>
<span><i data-kind="counter" />Counter</span>
</div>
</div>
<div className="donut-wrap">
<Donut share={onlineShare} total={format(total)} />
<div className="donut-legend">
<div>
<span className="donut-key" data-kind="online" />
<div>
<span className="donut-label">Online Sales</span>
<strong className="donut-pct">{onlineShare}%</strong>
<span className="donut-amt">{format(online)}</span>
</div>
</div>
<div>
<span className="donut-key" data-kind="counter" />
<div>
<span className="donut-label">Counter Sales</span>
<strong className="donut-pct">{100 - onlineShare}%</strong>
<span className="donut-amt">{format(counter)}</span>
</div>
</div>
</div>
</div>
</div>
)}
</section>
);
}
/**
* The channel split as a ring.
*
* One circle with a dash offset rather than two arcs: a single stroke cannot
* develop a seam at the join, and the whole thing animates from one number.
*/
function Donut({ share, total }: { share: number; total: string }) {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const filled = (share / 100) * circumference;
return (
<div className="donut">
<svg viewBox="0 0 140 140" role="img" aria-label={`Online sales ${share}% of the total`}>
<circle cx="70" cy="70" r={radius} className="donut-track" />
<circle
cx="70"
cy="70"
r={radius}
className="donut-fill"
strokeDasharray={`${filled} ${circumference - filled}`}
// Start at twelve o'clock; the default is three, which reads as a
// slice that has already been eaten.
transform="rotate(-90 70 70)"
/>
</svg>
<div className="donut-centre">
<span>Total Sales</span>
<strong>{total}</strong>
</div>
</div>
);
}
/** "2026-09-03" → "Sep 3". Falls back to the raw value rather than throwing. */
function shortDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
}

View File

@@ -0,0 +1,118 @@
import { Link } from 'react-router-dom';
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 { Boxes, PackageSearch, Store } from 'lucide-react';
import type { TenantInfo } from '@/api/types';
import { count, money } from '@/features/store-admin/format';
import type { BranchRow } from '../consoleModel';
/**
* The shop itself — who it is, and how today went.
*
* Shown when exactly one branch is in view. With several, the section becomes
* "Branch performance" and this is replaced by the branch table, because a
* single address and phone number under a thirteen-branch total is a lie about
* whose shop it is.
*
* The quick actions are the three things a shopkeeper opens the console to do,
* placed where they have just read the number that prompts them.
*/
export interface ShopSummaryProps {
row: BranchRow | undefined;
shop: TenantInfo | undefined;
base: string;
}
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
if (!row) {
return (
<Card padding={3} elevation="low">
<Text type="body" size="sm" color="secondary">
No branch is selected.
</Text>
</Card>
);
}
const name = row.branch.locationname?.trim() || shop?.tenantname?.trim() || 'Your shop';
const record = shop as unknown as Record<string, string | undefined> | undefined;
const where = [record?.['address'], record?.['city'], record?.['postcode']]
.map((part) => part?.trim())
.filter(Boolean)
.join(', ');
const total = row.onlineRevenue + row.counterRevenue;
return (
<Card padding={3} elevation="low">
<VStack gap={3}>
<HStack gap={2} align="start" wrap="wrap">
<span className="shop-mark" aria-hidden>
<Store size={22} />
</span>
<VStack gap={0.5} style={{ minWidth: 0, flex: 1 }}>
<HStack gap={1.5} align="center" wrap="wrap">
<Text type="label" size="lg" weight="semibold">
{name}
</Text>
<HStack gap={0.5} align="center">
<span className="status-dot" data-tone="healthy" aria-hidden />
<Text type="body" size="xsm" color="secondary">
Active
</Text>
</HStack>
</HStack>
{where ? (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.5 }}>
{where}
</Text>
) : null}
{record?.['primarycontact'] ? (
<Text type="body" size="sm" color="secondary">
{record['primarycontact']}
</Text>
) : null}
</VStack>
</HStack>
<div className="shop-figures">
<Figure label="Online" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
<Figure label="Counter" value={money(row.counterRevenue)} note={`${count(row.counterBills)} bills`} />
<Figure label="Total" value={money(total)} note="this period" isStrong />
</div>
<HStack gap={1.5} wrap="wrap">
<Link to={`${base}/inventory?tab=catalogue`} style={{ textDecoration: 'none' }}>
<Button label="Manage catalogue" variant="secondary" size="sm" icon={<Boxes size={14} />} />
</Link>
<Link to={`${base}/inventory?tab=stock`} style={{ textDecoration: 'none' }}>
<Button label="Update inventory" variant="secondary" size="sm" icon={<PackageSearch size={14} />} />
</Link>
<Link to={`${base}/inventory?tab=products`} style={{ textDecoration: 'none' }}>
<Button label="View store" variant="ghost" size="sm" icon={<Store size={14} />} />
</Link>
</HStack>
</VStack>
</Card>
);
}
function Figure({
label, value, note, isStrong,
}: { label: string; value: string; note: string; isStrong?: boolean }) {
return (
<VStack gap={0} className="shop-figure" data-strong={isStrong ? 'yes' : 'no'}>
<Text type="label" size="xsm" color="secondary">
{label}
</Text>
<Text type={isStrong ? 'display-1' : 'label'} size={isStrong ? undefined : 'lg'} weight="semibold" hasTabularNumbers>
{value}
</Text>
<Text type="body" size="xsm" color="secondary">
{note}
</Text>
</VStack>
);
}

View File

@@ -0,0 +1,151 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react';
import { count } from '@/features/store-admin/format';
import type { BranchRow, ConsoleTotals, HealthRollup, HealthTone } from '../consoleModel';
/**
* Whether the shop is working, as distinct from how much it sold.
*
* Four lines, each a thing that can be wrong and a way to go and fix it. Every
* figure is measured, not scored: "2 active / 3 total" is a fact a merchant can
* check at the counter, where "82% healthy" is one they can only take on trust.
*
* ── What is deliberately NOT here ───────────────────────────────────────────
*
* The reference design shows an "Inventory Sync — last synced 2 hours ago" row.
* Fiesta has no such signal: nothing anywhere records a sync time for stock, so
* that line could only ever be decoration. In its place is the queue that DOES
* exist and does block a shelf — undecided stock requests. A row backed by
* nothing is worse than a missing row, because it reads as reassurance.
*/
export interface StoreHealthProps {
rows: readonly BranchRow[];
totals: ConsoleTotals;
rollup: HealthRollup;
isAggregate: boolean;
productCount: number;
base: string;
}
export function StoreHealth({
rows, totals, rollup, isAggregate, productCount, base,
}: StoreHealthProps) {
const undecided = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
const withoutTill = rows.filter((row) => row.health.total === 0).length;
const tillTone: HealthTone =
totals.tillsTotal === 0 ? 'attention'
: totals.tillsOnline === 0 ? 'critical'
: totals.tillsOnline < totals.tillsTotal ? 'attention' : 'healthy';
return (
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Store health</h3>
<p className="panel-sub">Quick status of your key operations</p>
</div>
</header>
{isAggregate ? (
<div className="health-rollup">
<RollupChip tone="healthy" n={rollup.healthy} word="healthy" />
<RollupChip tone="attention" n={rollup.attention} word="need attention" />
<RollupChip tone="critical" n={rollup.offline} word="offline" />
</div>
) : null}
<div className="health-list">
<HealthLine
icon={<Monitor size={16} />}
name="Tills"
detail={
totals.tillsTotal === 0
? isAggregate ? 'No till connected to any branch' : 'No till connected to this shop'
: `${count(totals.tillsOnline)} active / ${count(totals.tillsTotal)} total`
}
tone={tillTone}
action="View details"
href={`${base}/terminals`}
/>
<HealthLine
icon={<Boxes size={16} />}
name="Bills syncing"
detail={
totals.pendingBills === 0
? 'Every counter bill has reached the books'
: `${count(totals.pendingBills)} waiting on a till`
}
tone={
totals.pendingBills === 0 ? 'healthy' : totals.pendingBills > 10 ? 'critical' : 'attention'
}
action="View tills"
href={`${base}/terminals`}
/>
<HealthLine
icon={<ClipboardList size={16} />}
name="Stock requests"
detail={
undecided === 0
? 'Nothing waiting on a decision'
: `${count(undecided)} undecided — nothing reaches a shelf until they are`
}
tone={undecided === 0 ? 'healthy' : 'attention'}
action="Review"
href={`${base}/inventory?tab=requests`}
/>
<HealthLine
icon={<Store size={16} />}
name="Catalogue"
detail={
productCount === 0
? 'No products yet, so shoppers find the shop empty'
: `${count(productCount)} products`
}
tone={productCount === 0 ? 'attention' : withoutTill > 0 ? 'attention' : 'healthy'}
action="Manage"
href={`${base}/inventory?tab=products`}
/>
</div>
</section>
);
}
function RollupChip({ tone, n, word }: { tone: HealthTone; n: number; word: string }) {
return (
<div className="rollup-chip" data-tone={tone}>
<strong>{n}</strong>
<span>{n === 1 ? 'branch' : 'branches'} {word}</span>
</div>
);
}
function HealthLine({
icon, name, detail, tone, action, href,
}: {
icon: ReactNode;
name: string;
detail: string;
tone: HealthTone;
action: string;
href: string;
}) {
return (
<div className="health-line">
{/* Brand-tinted, not status-tinted: the icon says WHAT this row is, and
the pill beside it says how it is doing. Colouring both made the
board read as four unrelated palettes. */}
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
<div className="health-text">
<span className="health-name">{name}</span>
<span className="health-detail">{detail}</span>
</div>
<span className="pill" data-tone={tone}>
<i />
{tone === 'healthy' ? 'Healthy' : tone === 'attention' ? 'Attention' : 'Critical'}
</span>
<Link to={href} className="btn-outline">{action}</Link>
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Search } from 'lucide-react';
import { branchLabel, count, money } from '@/features/store-admin/format';
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
import type { BranchRow, HealthTone } from '../consoleModel';
/**
* Tills, filtered before they are listed.
*
* The old version put every terminal on the dashboard, so a tenant with
* thirteen unhappy tills got a wall of red that pushed the rest of the board off
* the screen — and a wall of red reads as "the system is broken" rather than as
* thirteen separate things to do.
*
* Now the counts lead, the filter defaults to whatever needs a person, and only
* a handful of rows are shown. The full list has a page of its own.
*/
const SHOWN = 6;
export interface TillSyncProps {
rows: readonly BranchRow[];
showBranch: boolean;
base: string;
}
type Filter = 'all' | 'attention' | 'healthy';
interface TillRow {
terminal: TerminalStatus;
branchName: string;
tone: HealthTone;
}
export function TillSync({ rows, showBranch, base }: TillSyncProps) {
const tills = useMemo<TillRow[]>(
() =>
rows.flatMap((row) =>
row.health.terminals.map((terminal) => ({
terminal,
branchName: branchLabel(row.branch.locationname) || `Branch ${row.branch.locationid}`,
tone: toneOf(terminal),
})),
),
[rows],
);
const needing = tills.filter((t) => t.tone !== 'healthy').length;
const healthy = tills.length - needing;
// Opens on whatever needs a person. Listing eleven healthy tills above the
// four broken ones is the old mistake in a different order.
const [filter, setFilter] = useState<Filter>(() => (needing > 0 ? 'attention' : 'all'));
const [term, setTerm] = useState('');
const shown = useMemo(() => {
const needle = term.trim().toLowerCase();
return tills
.filter((t) =>
filter === 'all' ? true : filter === 'healthy' ? t.tone === 'healthy' : t.tone !== 'healthy',
)
.filter(
(t) =>
needle === '' ||
t.terminal.terminalId.toLowerCase().includes(needle) ||
t.branchName.toLowerCase().includes(needle),
)
.sort((a, b) => rank(a.tone) - rank(b.tone));
}, [tills, filter, term]);
if (tills.length === 0) {
return (
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Till sync</h3>
<p className="panel-sub">Monitor your tills and sync status</p>
</div>
</header>
<div className="panel-empty">
<strong>No tills connected</strong>
<span>
Counter sales are counted by a till. Until one is connected, only app orders appear on
this board.
</span>
</div>
</section>
);
}
const visible = shown.slice(0, SHOWN);
return (
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Till sync</h3>
<p className="panel-sub">Monitor your tills and sync status</p>
</div>
<div className="till-controls">
<div className="chip-row">
<button type="button" className="chip" data-active={filter === 'all'} onClick={() => setFilter('all')}>
All <b>{tills.length}</b>
</button>
<button type="button" className="chip" data-tone="attention" data-active={filter === 'attention'} onClick={() => setFilter('attention')}>
Attention <b>{needing}</b>
</button>
<button type="button" className="chip" data-tone="healthy" data-active={filter === 'healthy'} onClick={() => setFilter('healthy')}>
Healthy <b>{healthy}</b>
</button>
</div>
<label className="search">
<Search size={14} aria-hidden />
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={showBranch ? 'Search till ID, branch…' : 'Search till ID…'}
aria-label="Search tills"
/>
</label>
</div>
</header>
{visible.length === 0 ? (
<div className="panel-empty">
<strong>{filter === 'attention' ? 'No till needs attention' : 'Nothing matches'}</strong>
<span>
{filter === 'attention'
? 'Every till is reporting normally right now.'
: 'Try a different till ID or branch.'}
</span>
</div>
) : (
<div className="table-scroll">
<table className="grid-table">
<thead>
<tr>
<th>Till ID</th>
{showBranch ? <th>Branch</th> : null}
<th>Last heartbeat</th>
<th className="num">Today&rsquo;s bills</th>
<th className="num">Amount</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{visible.map(({ terminal, branchName, tone }) => (
<tr key={`${branchName}-${terminal.terminalId}`}>
<td><strong>{terminal.terminalId}</strong></td>
{showBranch ? <td>{branchName}</td> : null}
<td>
<span className="heartbeat" data-tone={tone}>
<i />
{terminal.silentForMs === null ? 'No heartbeat' : `${shortAge(terminal.silentForMs)} ago`}
</span>
</td>
<td className="num">{count(terminal.todayBills)}</td>
<td className="num">{money(terminal.todayAmount)}</td>
<td>
<span className="pill" data-tone={tone}><i />{SYNC_LABEL[terminal.state]}</span>
</td>
<td>
<Link
to={`${base}/terminals`}
className={tone === 'healthy' ? 'btn-outline' : 'btn-solid'}
{...(tone === 'healthy' ? {} : { 'data-tone': tone })}
>
{tone === 'healthy' ? 'View' : 'Sync Now'}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{shown.length > visible.length ? (
<footer className="panel-foot">
<span>Showing {visible.length} of {shown.length}.</span>
<Link to={`${base}/terminals`} className="link-quiet">View all tills →</Link>
</footer>
) : null}
</section>
);
}
/**
* One till, in the three words the dashboard cares about.
*
* `pendingBills` counts as attention even on an otherwise healthy terminal: a
* connected till holding unsent bills is exactly what makes revenue read low,
* and it is invisible if only the connection state is shown.
*/
function toneOf(terminal: TerminalStatus): HealthTone {
if (terminal.state === 'offline' || terminal.state === 'stale') return 'critical';
if (terminal.state === 'delayed' || terminal.pendingBills > 0) return 'attention';
return 'healthy';
}
const rank = (tone: HealthTone) => (tone === 'critical' ? 0 : tone === 'attention' ? 1 : 2);

View File

@@ -0,0 +1,137 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Boxes, ChevronRight, Eye, MapPin, Phone, Receipt, RefreshCw, ShoppingCart, Store } from 'lucide-react';
import type { TenantInfo } from '@/api/types';
import { count, money } from '@/features/store-admin/format';
import type { BranchRow } from '../consoleModel';
/**
* The shop: who it is, what it took, and what to do next.
*
* ── Why identity and takings share one card ─────────────────────────────────
*
* They were two panels side by side, and both were half empty: a name and an
* address do not fill a panel, and neither do two figures. Worse, the row
* stretched every panel to match the tallest, so the gap was proportional to
* how much the THIRD panel had to say. Three cards each doing a third of a job
* is not a layout, it is a grid with the content spread thin to fit it.
*
* They also belong together: "R mart" and "R mart took ₹12,480 today" are one
* statement about one shop, and the border between them was inventing a
* distinction the reader does not have.
*
* So two panels, not three — the shop and the things to do to it. The figures
* sit inside the shop card, beneath the address they belong to.
*/
export interface YourShopProps {
row: BranchRow | undefined;
shop: TenantInfo | undefined;
title: string;
base: string;
}
export function YourShop({ row, shop, title, base }: YourShopProps) {
if (!row) {
return (
<section className="panel">
<div className="panel-empty">
<strong>No branch selected</strong>
<span>Pick a branch from the selector above to see its details.</span>
</div>
</section>
);
}
const record = shop as unknown as Record<string, string | undefined> | undefined;
const name = row.branch.locationname?.trim() || record?.['tenantname']?.trim() || 'Your shop';
const where = [record?.['address'], record?.['city'], record?.['postcode']]
.map((part) => part?.trim())
.filter(Boolean)
.join(', ');
const total = row.onlineRevenue + row.counterRevenue;
return (
<div className="shop-row">
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">{title}</h3>
<p className="panel-sub">Today&rsquo;s performance and where customers find you</p>
</div>
<span className="pill" data-tone="healthy"><i />Active</span>
</header>
<div className="shop-id">
<span className="shop-mark" aria-hidden><Store size={24} /></span>
<div className="shop-idtext">
<strong className="shop-title">{name}</strong>
{where ? <p className="shop-meta"><MapPin size={13} />{where}</p> : null}
{record?.['primarycontact'] ? (
<p className="shop-meta"><Phone size={13} />{record['primarycontact']}</p>
) : null}
</div>
</div>
{/* Three figures rather than two: the total is what a shopkeeper is
actually after, and making them add it up from two channels is the
arithmetic this board exists to remove. */}
<div className="perf-row">
<Figure
icon={<ShoppingCart size={13} />}
label="Online"
value={money(row.onlineRevenue)}
note={`${count(row.onlineOrders)} ${row.onlineOrders === 1 ? 'order' : 'orders'}`}
/>
<Figure
icon={<Receipt size={13} />}
label="Counter"
value={money(row.counterRevenue)}
note={`${count(row.counterBills)} ${row.counterBills === 1 ? 'bill' : 'bills'}`}
/>
<Figure label="Total" value={money(total)} note="both channels" isStrong />
</div>
</section>
<section className="panel">
<header className="panel-head">
<div>
<h3 className="panel-title">Quick actions</h3>
<p className="panel-sub">The three things most often done from here</p>
</div>
</header>
<div className="quick-list">
<QuickAction to={`${base}/inventory?tab=products`} icon={<Eye size={15} />} label="View store" note="what a shopper sees" />
<QuickAction to={`${base}/inventory?tab=catalogue`} icon={<Boxes size={15} />} label="Manage catalogue" note="add and price products" />
<QuickAction to={`${base}/inventory?tab=stock`} icon={<RefreshCw size={15} />} label="Update inventory" note="upload today's counts" />
</div>
</section>
</div>
);
}
function Figure({
icon, label, value, note, isStrong,
}: { icon?: ReactNode; label: string; value: string; note: string; isStrong?: boolean }) {
return (
<div className="perf" data-strong={isStrong ? 'yes' : 'no'}>
<span className="perf-label">{icon}{label}</span>
<strong>{value}</strong>
<span className="perf-note">{note}</span>
</div>
);
}
function QuickAction({
to, icon, label, note,
}: { to: string; icon: ReactNode; label: string; note: string }) {
return (
<Link to={to} className="quick">
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
<span className="quick-text">
<span className="quick-label">{label}</span>
<span className="quick-note">{note}</span>
</span>
<ChevronRight size={15} className="quick-chev" />
</Link>
);
}

View File

@@ -0,0 +1,194 @@
import { useMemo } from 'react';
import type { DateRange } from '@/api/insights';
import { useOrders } from '@/queries/hooks';
import type { PosSalesSummary } from '@/api/types';
/**
* The day-by-day series behind the chart, the donut and the trend arrows.
*
* ── Why the online side is built from order rows ────────────────────────────
*
* `getlocationsummary` gives one figure per branch for all time — no dates, no
* days — so it can say what a shop has ever taken and nothing about a period.
* The counter side already returns `byday`. Rather than draw half a chart, the
* online series is grouped from the order rows themselves, which carry
* `orderdate` and an amount and can be asked for a date range.
*
* ── Why the previous period is fetched at all ───────────────────────────────
*
* "↑ 12% vs. yesterday" is a claim, and a dashboard that invents it is worse
* than one that omits it. The comparison window is the same length as the one
* on screen, immediately before it, and every trend shown is a real division of
* two measured numbers. Where a figure has no comparable — an all-time count
* with no date to filter on — no arrow is drawn.
*/
export interface DaySeries {
day: string;
online: number;
counter: number;
onlineOrders: number;
counterBills: number;
}
export interface Trend {
/** Percentage change, rounded. Absent when there is nothing to compare. */
percent?: number;
/** True when the previous period was zero, so a percentage would be a lie. */
isNew: boolean;
direction: 'up' | 'down' | 'flat';
}
export interface ConsoleSeries {
days: DaySeries[];
onlineRevenue: number;
onlineOrders: number;
cancelled: number;
trends: {
online: Trend;
counter: Trend;
orders: Trend;
cancelled: Trend;
};
isLoading: boolean;
}
/** The window of the same length immediately before this one. */
export function previousRange(range: DateRange): DateRange {
const from = range.fromdate ? new Date(range.fromdate) : undefined;
const to = range.todate ? new Date(range.todate) : undefined;
if (!from || !to || Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return {};
const days = Math.max(1, Math.round((to.getTime() - from.getTime()) / 86_400_000) + 1);
const prevTo = new Date(from);
prevTo.setDate(prevTo.getDate() - 1);
const prevFrom = new Date(prevTo);
prevFrom.setDate(prevFrom.getDate() - (days - 1));
return { fromdate: iso(prevFrom), todate: iso(prevTo) };
}
export function trendOf(now: number, before: number): Trend {
if (before <= 0) {
// Everything is up from nothing, and "↑ ∞%" is not a fact. Say it is new.
return { isNew: now > 0, direction: now > 0 ? 'up' : 'flat' };
}
const percent = Math.round(((now - before) / before) * 100);
return {
percent: Math.abs(percent),
isNew: false,
direction: percent > 0 ? 'up' : percent < 0 ? 'down' : 'flat',
};
}
export function useConsoleSeries(
tenantid: number,
locationid: number | null,
range: DateRange,
posNow: readonly PosSalesSummary[],
posBefore: readonly PosSalesSummary[],
): ConsoleSeries {
const prev = useMemo(() => previousRange(range), [range]);
const nowOrders = useOrders(
tenantid ? { tenantid, ...(locationid ? { locationid } : {}), ...range, pagesize: 500 } : undefined,
);
const beforeOrders = useOrders(
tenantid && prev.fromdate
? { tenantid, ...(locationid ? { locationid } : {}), ...prev, pagesize: 500 }
: undefined,
);
return useMemo(() => {
const byDay = new Map<string, DaySeries>();
const at = (day: string) => {
const found = byDay.get(day) ?? { day, online: 0, counter: 0, onlineOrders: 0, counterBills: 0 };
byDay.set(day, found);
return found;
};
let onlineRevenue = 0;
let onlineOrders = 0;
let cancelled = 0;
for (const order of nowOrders.data ?? []) {
const day = dayOf(order.orderdate);
const amount = amountOf(order);
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
onlineOrders += 1;
if (isCancelled) cancelled += 1;
else onlineRevenue += amount;
if (day) {
const row = at(day);
row.onlineOrders += 1;
if (!isCancelled) row.online += amount;
}
}
for (const summary of posNow) {
for (const entry of summary.byday ?? []) {
const day = dayOf(entry.day);
if (!day) continue;
const row = at(day);
row.counter += Number(entry.amount ?? 0);
row.counterBills += Number(entry.billcount ?? 0);
}
}
let beforeOnline = 0;
let beforeOrders_ = 0;
let beforeCancelled = 0;
for (const order of beforeOrders.data ?? []) {
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
beforeOrders_ += 1;
if (isCancelled) beforeCancelled += 1;
else beforeOnline += amountOf(order);
}
const counterNow = posNow.reduce((sum, s) => sum + Number(s.grosssales ?? 0), 0);
const counterBefore = posBefore.reduce((sum, s) => sum + Number(s.grosssales ?? 0), 0);
const billsNow = posNow.reduce((sum, s) => sum + Number(s.billcount ?? 0), 0);
const billsBefore = posBefore.reduce((sum, s) => sum + Number(s.billcount ?? 0), 0);
return {
days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)),
onlineRevenue,
onlineOrders,
cancelled,
trends: {
online: trendOf(onlineRevenue, beforeOnline),
counter: trendOf(counterNow, counterBefore),
orders: trendOf(onlineOrders + billsNow, beforeOrders_ + billsBefore),
cancelled: trendOf(cancelled, beforeCancelled),
},
isLoading: nowOrders.isLoading,
};
}, [nowOrders.data, nowOrders.isLoading, beforeOrders.data, posNow, posBefore]);
}
/* ── Reading the wire ─────────────────────────────────────────────────────── */
/** "2026-09-03T15:32:48" and "2026-09-03" both become "2026-09-03". */
function dayOf(value: string | undefined): string {
const raw = String(value ?? '').trim();
if (!raw) return '';
const match = raw.match(/^\d{4}-\d{2}-\d{2}/);
if (match) return match[0];
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? '' : iso(parsed);
}
/**
* The fallback chain the old console used, and for the same reason:
* `ordervalue` is the full figure (goods + tax + charges − promo),
* `orderamount` is the goods alone, and a row may carry either.
*/
function amountOf(order: { ordervalue?: number; orderamount?: number }): number {
return Number(order.ordervalue || order.orderamount || 0);
}
function iso(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
@@ -81,13 +82,15 @@ export function StoreAdminShell() {
<BranchScopeProvider>
<LiveWatch />
<OnboardingGate />
<AppShell
nav={NAV}
home="/admin/console"
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
/>
<AssistantScope>
<AppShell
nav={NAV}
home="/admin/console"
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
/>
</AssistantScope>
</BranchScopeProvider>
);
}

View File

@@ -5,6 +5,7 @@ import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/component
import { StoreUserTour } from '@/features/setup/StoreUserTour';
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations } from '@/queries/hooks';
import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
@@ -112,19 +113,21 @@ export function StoreUserShell() {
return (
<BranchScopeProvider pin={locationid}>
<LiveWatch />
<AppShell
nav={NAV}
home="/store/console"
navLabel="Store"
scopeControl={<BranchPill />}
manageItems={MANAGE}
banner={<StoreUserTour />}
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />
</IconButton>
}
/>
<AssistantScope>
<AppShell
nav={NAV}
home="/store/console"
navLabel="Store"
scopeControl={<BranchPill />}
manageItems={MANAGE}
banner={<StoreUserTour />}
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />
</IconButton>
}
/>
</AssistantScope>
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
</BranchScopeProvider>
);

View File

@@ -1732,3 +1732,4 @@ main {
}
.qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }