diff --git a/src/App.tsx b/src/App.tsx index 5466f45..4b82c53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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')); diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index f63a352..2d8e98a 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -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), diff --git a/src/components/shell/assistantScope.ts b/src/components/shell/assistantScope.ts new file mode 100644 index 0000000..104bb32 --- /dev/null +++ b/src/components/shell/assistantScope.ts @@ -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(undefined); + +export function useAssistantScope(): string | undefined { + return useContext(AssistantScopeContext); +} diff --git a/src/features/console/AssistantScope.tsx b/src/features/console/AssistantScope.tsx new file mode 100644 index 0000000..662825b --- /dev/null +++ b/src/features/console/AssistantScope.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/features/console/ConsolePage.tsx b/src/features/console/ConsolePage.tsx new file mode 100644 index 0000000..d05acba --- /dev/null +++ b/src/features/console/ConsolePage.tsx @@ -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('today'); + const [range, setRange] = useState(() => 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( + () => + 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 ( +
+
+
+
+

Console

+ {scope.countLabel} + Live +
+

{scope.blurb}

+
+ +
+ { + setPreset(nextPreset); + setRange(nextRange); + }} + /> +
+
+ + + +
+ + +
+ + {scope.showBranchOverview ? ( +
+
+
+

Branch performance

+

+ Ordered by takings — open one to scope the whole board to it +

+
+
+ +
+ ) : ( + + )} + + + + +
+ ); +} diff --git a/src/features/console/console.css b/src/features/console/console.css new file mode 100644 index 0000000..72086aa --- /dev/null +++ b/src/features/console/console.css @@ -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; } +} diff --git a/src/features/console/consoleModel.test.ts b/src/features/console/consoleModel.test.ts new file mode 100644 index 0000000..b267544 --- /dev/null +++ b/src/features/console/consoleModel.test.ts @@ -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 => ({ + state: 'synced' as TerminalState, + online: 2, + total: 2, + pendingBills: 0, + oldestPendingMs: 0, + lastBillAt: null, + terminals: [], + ...over, +}); + +const row = (id: number, over: Partial = {}): 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); + } +}); diff --git a/src/features/console/consoleModel.ts b/src/features/console/consoleModel.ts new file mode 100644 index 0000000..c5ee9e9 --- /dev/null +++ b/src/features/console/consoleModel.ts @@ -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( + (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 = { critical: 0, attention: 1, healthy: 2 }; + return alerts.sort((a, b) => order[a.severity] - order[b.severity]); +} diff --git a/src/features/console/consoleScope.test.ts b/src/features/console/consoleScope.test.ts new file mode 100644 index 0000000..021aa1f --- /dev/null +++ b/src/features/console/consoleScope.test.ts @@ -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.'); +}); diff --git a/src/features/console/consoleScope.ts b/src/features/console/consoleScope.ts new file mode 100644 index 0000000..ec5f219 --- /dev/null +++ b/src/features/console/consoleScope.ts @@ -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, + }; +} diff --git a/src/features/console/sections/BranchOverview.tsx b/src/features/console/sections/BranchOverview.tsx new file mode 100644 index 0000000..a074ba6 --- /dev/null +++ b/src/features/console/sections/BranchOverview.tsx @@ -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 ( + + + No branches are set up yet. + + + ); + } + + const ordered = [...rows].sort( + (a, b) => b.onlineRevenue + b.counterRevenue - (a.onlineRevenue + a.counterRevenue), + ); + + return ( + +
+ + + + + + + + + + + + + + {ordered.map((row) => { + const tone = branchTone(row.health); + const total = row.onlineRevenue + row.counterRevenue; + return ( + + + + + + + + + + + ); + })} + +
BranchOnlineCounterTotalOrdersTillsHealth +
+ + {branchLabel(row.branch.locationname) || `Branch ${row.branch.locationid}`} + + {money(row.onlineRevenue)}{money(row.counterRevenue)}{money(total)}{count(row.onlineOrders + row.counterBills)} + {row.health.total === 0 + ? '—' + : `${row.health.online}/${row.health.total}`} + + + + + {tone === 'healthy' ? 'Healthy' : tone === 'attention' ? 'Attention' : 'Offline'} + + + +
+
+
+ ); +} diff --git a/src/features/console/sections/KpiStrip.tsx b/src/features/console/sections/KpiStrip.tsx new file mode 100644 index 0000000..38effde --- /dev/null +++ b/src/features/console/sections/KpiStrip.tsx @@ -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 ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+ } + label="Online Sales" + value={money(totals.onlineRevenue)} + trend={trends.online} + comparisonLabel={comparisonLabel} + series={days.map((d) => d.online)} + /> + } + label="Counter Sales" + value={money(totals.counterRevenue)} + trend={trends.counter} + comparisonLabel={comparisonLabel} + series={days.map((d) => d.counter)} + /> + } + label="Total Orders" + value={count(totals.totalOrders)} + trend={trends.orders} + comparisonLabel={comparisonLabel} + series={days.map((d) => d.onlineOrders + d.counterBills)} + /> + } + 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 + /> + } + 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 + /> +
+ ); +} + +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 ( +
+ {/* 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. */} +
+ {icon} +
+ {label} + {value} +
+
+ +
+ {trend && !trend.isNew && trend.percent !== undefined ? ( +
+ {trend.direction === 'down' ? : } + {trend.percent}% + vs. {comparisonLabel} +
+ ) : ( +
+ {note ?? (trend?.isNew ? 'new this period' : `vs. ${comparisonLabel}`)} +
+ )} + +
+
+ ); +} + +/** + * 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 ( + + + + ); +} diff --git a/src/features/console/sections/NeedsAttention.tsx b/src/features/console/sections/NeedsAttention.tsx new file mode 100644 index 0000000..93d8265 --- /dev/null +++ b/src/features/console/sections/NeedsAttention.tsx @@ -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 ( +
+
+ +
+ You’re all caught up + + {isAggregate + ? 'No branch is reporting anything that needs you right now.' + : 'Nothing needs your attention right now.'} + +
+
+
+ ); + } + + const shown = alerts.slice(0, SHOWN); + + return ( +
+
+
+ +
+

Needs your attention

+

+ These items need your action to keep your store running smoothly. +

+
+
+ {alerts.length > SHOWN ? ( + + View all ({alerts.length}) + + ) : null} +
+ +
+ {shown.map((alert) => ( +
+ +
+ + {alert.branch} — {alert.title} + + {alert.detail} +
+ + {alert.severity === 'critical' ? 'High' : 'Medium'} + + + {alert.actionLabel} + +
+ ))} +
+
+ ); +} diff --git a/src/features/console/sections/SalesOverview.tsx b/src/features/console/sections/SalesOverview.tsx new file mode 100644 index 0000000..4c49885 --- /dev/null +++ b/src/features/console/sections/SalesOverview.tsx @@ -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('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
; + + return ( +
+
+
+

Sales overview

+

+ {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`} +

+
+
+ {(['revenue', 'orders'] as const).map((m) => ( + + ))} +
+
+ + {total === 0 ? ( +
+ No sales in this period + Pick a wider date range, or check back once the shop has taken an order. +
+ ) : ( +
+
+ {/* 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. */} +
+ {[1, 0.75, 0.5, 0.25, 0].map((step) => ( + {format(Math.round(peak * step))} + ))} +
+ +
+ {series.map((d) => ( +
+
+ + +
+ {shortDay(d.day)} +
+ ))} +
+ +
+ Online + Counter +
+
+ +
+ +
+
+ +
+ Online Sales + {onlineShare}% + {format(online)} +
+
+
+ +
+ Counter Sales + {100 - onlineShare}% + {format(counter)} +
+
+
+
+
+ )} +
+ ); +} + +/** + * 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 ( +
+ + + + +
+ Total Sales + {total} +
+
+ ); +} + +/** "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' }); +} diff --git a/src/features/console/sections/ShopSummary.tsx b/src/features/console/sections/ShopSummary.tsx new file mode 100644 index 0000000..58db3f9 --- /dev/null +++ b/src/features/console/sections/ShopSummary.tsx @@ -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 ( + + + No branch is selected. + + + ); + } + + const name = row.branch.locationname?.trim() || shop?.tenantname?.trim() || 'Your shop'; + const record = shop as unknown as Record | undefined; + const where = [record?.['address'], record?.['city'], record?.['postcode']] + .map((part) => part?.trim()) + .filter(Boolean) + .join(', '); + const total = row.onlineRevenue + row.counterRevenue; + + return ( + + + + + + + + + + {name} + + + + + Active + + + + {where ? ( + + {where} + + ) : null} + {record?.['primarycontact'] ? ( + + {record['primarycontact']} + + ) : null} + + + +
+
+
+
+
+ + + + + + +
+ + +
+ + + {visible.length === 0 ? ( +
+ {filter === 'attention' ? 'No till needs attention' : 'Nothing matches'} + + {filter === 'attention' + ? 'Every till is reporting normally right now.' + : 'Try a different till ID or branch.'} + +
+ ) : ( +
+ + + + + {showBranch ? : null} + + + + + + + + + {visible.map(({ terminal, branchName, tone }) => ( + + + {showBranch ? : null} + + + + + + + ))} + +
Till IDBranchLast heartbeatToday’s billsAmountStatusAction
{terminal.terminalId}{branchName} + + + {terminal.silentForMs === null ? 'No heartbeat' : `${shortAge(terminal.silentForMs)} ago`} + + {count(terminal.todayBills)}{money(terminal.todayAmount)} + {SYNC_LABEL[terminal.state]} + + + {tone === 'healthy' ? 'View' : 'Sync Now'} + +
+
+ )} + + {shown.length > visible.length ? ( +
+ Showing {visible.length} of {shown.length}. + View all tills → +
+ ) : null} + + ); +} + +/** + * 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); diff --git a/src/features/console/sections/YourShop.tsx b/src/features/console/sections/YourShop.tsx new file mode 100644 index 0000000..1977bd1 --- /dev/null +++ b/src/features/console/sections/YourShop.tsx @@ -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 ( +
+
+ No branch selected + Pick a branch from the selector above to see its details. +
+
+ ); + } + + const record = shop as unknown as Record | 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 ( +
+
+
+
+

{title}

+

Today’s performance and where customers find you

+
+ Active +
+ +
+ +
+ {name} + {where ?

{where}

: null} + {record?.['primarycontact'] ? ( +

{record['primarycontact']}

+ ) : null} +
+
+ + {/* 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. */} +
+
} + label="Online" + value={money(row.onlineRevenue)} + note={`${count(row.onlineOrders)} ${row.onlineOrders === 1 ? 'order' : 'orders'}`} + /> +
} + label="Counter" + value={money(row.counterRevenue)} + note={`${count(row.counterBills)} ${row.counterBills === 1 ? 'bill' : 'bills'}`} + /> +
+
+
+ +
+
+
+

Quick actions

+

The three things most often done from here

+
+
+
+ } label="View store" note="what a shopper sees" /> + } label="Manage catalogue" note="add and price products" /> + } label="Update inventory" note="upload today's counts" /> +
+
+
+ ); +} + +function Figure({ + icon, label, value, note, isStrong, +}: { icon?: ReactNode; label: string; value: string; note: string; isStrong?: boolean }) { + return ( +
+ {icon}{label} + {value} + {note} +
+ ); +} + +function QuickAction({ + to, icon, label, note, +}: { to: string; icon: ReactNode; label: string; note: string }) { + return ( + + {icon} + + {label} + {note} + + + + ); +} diff --git a/src/features/console/useConsoleSeries.ts b/src/features/console/useConsoleSeries.ts new file mode 100644 index 0000000..c895baa --- /dev/null +++ b/src/features/console/useConsoleSeries.ts @@ -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(); + 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')}`; +} diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx index fb86fce..dd82174 100644 --- a/src/features/store-admin/StoreAdminShell.tsx +++ b/src/features/store-admin/StoreAdminShell.tsx @@ -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() { - } - manageItems={MANAGE} - /> + + } + manageItems={MANAGE} + /> + ); } diff --git a/src/features/store-user/StoreUserShell.tsx b/src/features/store-user/StoreUserShell.tsx index c1cd6b3..c2752ae 100644 --- a/src/features/store-user/StoreUserShell.tsx +++ b/src/features/store-user/StoreUserShell.tsx @@ -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 ( - } - manageItems={MANAGE} - banner={} - headerActions={ - setQrOpen(true)}> - - - } - /> + + } + manageItems={MANAGE} + banner={} + headerActions={ + setQrOpen(true)}> + + + } + /> + {isQrOpen ? setQrOpen(false)} /> : null} ); diff --git a/src/index.css b/src/index.css index 2a1ea3e..75c56b2 100644 --- a/src/index.css +++ b/src/index.css @@ -1732,3 +1732,4 @@ main { } .qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; } +