redesign console page
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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),
|
||||
|
||||
21
src/components/shell/assistantScope.ts
Normal file
21
src/components/shell/assistantScope.ts
Normal 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);
|
||||
}
|
||||
32
src/features/console/AssistantScope.tsx
Normal file
32
src/features/console/AssistantScope.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/features/console/ConsolePage.tsx
Normal file
206
src/features/console/ConsolePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
346
src/features/console/console.css
Normal file
346
src/features/console/console.css
Normal 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; }
|
||||
}
|
||||
129
src/features/console/consoleModel.test.ts
Normal file
129
src/features/console/consoleModel.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
193
src/features/console/consoleModel.ts
Normal file
193
src/features/console/consoleModel.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
import type { BranchSyncSummary } from '@/features/store-admin/posStatus';
|
||||
|
||||
/**
|
||||
* The board's arithmetic, separated from its layout.
|
||||
*
|
||||
* Every number on the Console is derived from the same per-branch rows, and the
|
||||
* aggregate is a sum of exactly the rows in scope — never a separate query with
|
||||
* its own idea of the total. That is what makes "All branches" honest: the sum
|
||||
* shown is the sum of the branches listed under it.
|
||||
*/
|
||||
|
||||
export interface BranchRow {
|
||||
branch: TenantLocation;
|
||||
onlineRevenue: number;
|
||||
onlineOrders: number;
|
||||
cancelled: number;
|
||||
delivered: number;
|
||||
counterRevenue: number;
|
||||
counterBills: number;
|
||||
health: BranchSyncSummary;
|
||||
pendingRequests: number;
|
||||
}
|
||||
|
||||
export interface ConsoleTotals {
|
||||
onlineRevenue: number;
|
||||
onlineOrders: number;
|
||||
cancelled: number;
|
||||
counterRevenue: number;
|
||||
counterBills: number;
|
||||
pendingBills: number;
|
||||
totalRevenue: number;
|
||||
totalOrders: number;
|
||||
tillsOnline: number;
|
||||
tillsTotal: number;
|
||||
}
|
||||
|
||||
export function totalsOf(rows: readonly BranchRow[]): ConsoleTotals {
|
||||
const totals = rows.reduce<ConsoleTotals>(
|
||||
(acc, row) => ({
|
||||
onlineRevenue: acc.onlineRevenue + row.onlineRevenue,
|
||||
onlineOrders: acc.onlineOrders + row.onlineOrders,
|
||||
cancelled: acc.cancelled + row.cancelled,
|
||||
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||
counterBills: acc.counterBills + row.counterBills,
|
||||
pendingBills: acc.pendingBills + row.health.pendingBills,
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
tillsOnline: acc.tillsOnline + row.health.online,
|
||||
tillsTotal: acc.tillsTotal + row.health.total,
|
||||
}),
|
||||
{
|
||||
onlineRevenue: 0, onlineOrders: 0, cancelled: 0, counterRevenue: 0,
|
||||
counterBills: 0, pendingBills: 0, totalRevenue: 0, totalOrders: 0,
|
||||
tillsOnline: 0, tillsTotal: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Derived last so the two channels cannot disagree with their own sum.
|
||||
totals.totalRevenue = totals.onlineRevenue + totals.counterRevenue;
|
||||
totals.totalOrders = totals.onlineOrders + totals.counterBills;
|
||||
return totals;
|
||||
}
|
||||
|
||||
/* ── Health ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type HealthTone = 'healthy' | 'attention' | 'critical';
|
||||
|
||||
export interface HealthRollup {
|
||||
healthy: number;
|
||||
attention: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How one branch is doing, in three words rather than six states.
|
||||
*
|
||||
* `posStatus` distinguishes synced/syncing/delayed/stale/offline because a till
|
||||
* screen needs that much detail. A dashboard does not: the question here is
|
||||
* whether somebody has to go and do something, so the states collapse to fine,
|
||||
* look at it, or it is down.
|
||||
*/
|
||||
export function branchTone(health: BranchSyncSummary): HealthTone {
|
||||
if (health.total === 0) return 'attention';
|
||||
if (health.online === 0) return 'critical';
|
||||
if (health.state === 'stale' || health.state === 'offline') return 'critical';
|
||||
if (health.state === 'delayed' || health.online < health.total) return 'attention';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function rollupHealth(rows: readonly BranchRow[]): HealthRollup {
|
||||
const rollup: HealthRollup = { healthy: 0, attention: 0, offline: 0 };
|
||||
for (const row of rows) {
|
||||
const tone = branchTone(row.health);
|
||||
if (tone === 'critical') rollup.offline += 1;
|
||||
else if (tone === 'attention') rollup.attention += 1;
|
||||
else rollup.healthy += 1;
|
||||
}
|
||||
return rollup;
|
||||
}
|
||||
|
||||
/* ── Attention ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface ConsoleAlert {
|
||||
id: string;
|
||||
severity: HealthTone;
|
||||
branch: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
actionLabel: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What somebody has to do something about, worst first.
|
||||
*
|
||||
* Only real conditions, and each one names the branch even on a single-branch
|
||||
* board — an alert a merchant might screenshot and send to a colleague should
|
||||
* say where it came from.
|
||||
*
|
||||
* Deliberately not "every anomaly". A dashboard that lists thirty warnings
|
||||
* teaches people to scroll past the section, and then the one that mattered is
|
||||
* scrolled past too.
|
||||
*/
|
||||
export function buildAlerts(rows: readonly BranchRow[], base: string): ConsoleAlert[] {
|
||||
const alerts: ConsoleAlert[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.branch.locationname?.trim() || `Branch ${row.branch.locationid}`;
|
||||
const health = row.health;
|
||||
|
||||
if (health.total > 0 && health.online === 0) {
|
||||
alerts.push({
|
||||
id: `tills-down-${row.branch.locationid}`,
|
||||
severity: 'critical',
|
||||
branch: name,
|
||||
title: health.total === 1 ? 'Till not reporting' : `${health.total} tills not reporting`,
|
||||
detail: 'No heartbeat, so counter sales are not reaching the books.',
|
||||
actionLabel: 'Check tills',
|
||||
href: `${base}/terminals`,
|
||||
});
|
||||
} else if (health.online < health.total) {
|
||||
alerts.push({
|
||||
id: `tills-part-${row.branch.locationid}`,
|
||||
severity: 'attention',
|
||||
branch: name,
|
||||
title: `${health.total - health.online} of ${health.total} tills offline`,
|
||||
detail: 'The rest are reporting normally.',
|
||||
actionLabel: 'View tills',
|
||||
href: `${base}/terminals`,
|
||||
});
|
||||
}
|
||||
|
||||
if (health.pendingBills > 0) {
|
||||
alerts.push({
|
||||
id: `bills-${row.branch.locationid}`,
|
||||
severity: health.pendingBills > 10 ? 'critical' : 'attention',
|
||||
branch: name,
|
||||
title: `${health.pendingBills} bill${health.pendingBills === 1 ? '' : 's'} not synced`,
|
||||
detail: 'Taken at the counter and not yet in the books, so revenue reads low.',
|
||||
actionLabel: 'View tills',
|
||||
href: `${base}/terminals`,
|
||||
});
|
||||
}
|
||||
|
||||
if (row.pendingRequests > 0) {
|
||||
alerts.push({
|
||||
id: `requests-${row.branch.locationid}`,
|
||||
severity: 'attention',
|
||||
branch: name,
|
||||
title: `${row.pendingRequests} stock request${row.pendingRequests === 1 ? '' : 's'} waiting`,
|
||||
detail: 'Nothing reaches a shelf until these are decided.',
|
||||
actionLabel: 'Review',
|
||||
href: `${base}/inventory?tab=requests`,
|
||||
});
|
||||
}
|
||||
|
||||
if (row.cancelled > 0 && row.onlineOrders > 0 && row.cancelled / row.onlineOrders >= 0.2) {
|
||||
alerts.push({
|
||||
id: `cancels-${row.branch.locationid}`,
|
||||
severity: 'attention',
|
||||
branch: name,
|
||||
title: `${Math.round((row.cancelled / row.onlineOrders) * 100)}% of orders cancelled`,
|
||||
detail: `${row.cancelled} of ${row.onlineOrders} app orders did not complete.`,
|
||||
actionLabel: 'View sales',
|
||||
href: `${base}/sales`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const order: Record<HealthTone, number> = { critical: 0, attention: 1, healthy: 2 };
|
||||
return alerts.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||
}
|
||||
67
src/features/console/consoleScope.test.ts
Normal file
67
src/features/console/consoleScope.test.ts
Normal 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.');
|
||||
});
|
||||
82
src/features/console/consoleScope.ts
Normal file
82
src/features/console/consoleScope.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
src/features/console/sections/BranchOverview.tsx
Normal file
105
src/features/console/sections/BranchOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/features/console/sections/KpiStrip.tsx
Normal file
171
src/features/console/sections/KpiStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/features/console/sections/NeedsAttention.tsx
Normal file
86
src/features/console/sections/NeedsAttention.tsx
Normal 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’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} — <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>
|
||||
);
|
||||
}
|
||||
193
src/features/console/sections/SalesOverview.tsx
Normal file
193
src/features/console/sections/SalesOverview.tsx
Normal 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' });
|
||||
}
|
||||
118
src/features/console/sections/ShopSummary.tsx
Normal file
118
src/features/console/sections/ShopSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/features/console/sections/StoreHealth.tsx
Normal file
151
src/features/console/sections/StoreHealth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/features/console/sections/TillSync.tsx
Normal file
204
src/features/console/sections/TillSync.tsx
Normal 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’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);
|
||||
137
src/features/console/sections/YourShop.tsx
Normal file
137
src/features/console/sections/YourShop.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
194
src/features/console/useConsoleSeries.ts
Normal file
194
src/features/console/useConsoleSeries.ts
Normal 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')}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1732,3 +1732,4 @@ main {
|
||||
}
|
||||
|
||||
.qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user