import { useMemo } from 'react'; import { useDateScope } from '@/components/shell/DateScope'; import { useBranchScope } from '@/features/store-admin/BranchScope'; import { summariseBranch } from '@/features/store-admin/posStatus'; import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats'; import { useLocationProducts, useOrders, usePosHealthByBranch, usePosSalesByBranch, useStockRequests, } from '@/queries/hooks'; import { buildAlerts, totalsOf, type BranchRow } from './consoleModel'; import { consoleScope } from './consoleScope'; import { 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 { QuickActions } from './sections/QuickActions'; 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 dates = useDateScope(); const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); /* The same rows `useConsoleSeries` reads, so this shares its cache entry rather than adding a request. `getlocationsummary`, which used to feed this table, carries no money and ignores the date picker — see `branchStats.ts`. */ const orders = useOrders( tenantid ? { tenantid, ...(selected ? { locationid: selected } : {}), ...dates.range, pagesize: 500 } : undefined, ); const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]); const posNow = usePosSalesByBranch(branchIds, dates.range); const posHealth = usePosHealthByBranch(branchIds); 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 series = useConsoleSeries(tenantid, selected, dates.range, posNowData); // 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 = byBranch.get(branch.locationid) ?? NO_ORDERS; const pos = posNow[index]?.data; return { branch, onlineRevenue: order.revenue, onlineOrders: order.orders, cancelled: order.cancelled, delivered: order.delivered, // Both routes to a counter sale — a till that synced, and a till // that was offline whose day was imported as OFFLINE-tagged orders. // See the note in the Store Admin console, which had the same gap. counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue, counterBills: (pos?.billcount ?? 0) + order.counterOrders, health: summariseBranch(posHealth[index]?.data ?? [], now), pendingRequests: (requests.data ?? []).filter( (entry) => entry.locationid === branch.locationid, ).length, }; }), [scoped, byBranch, 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 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

{/* Full width. A trend needs the page: at two thirds it showed six days before scrolling, which is not a trend, it is a sample. */} {/* Store health takes the slot the shop card had. The shop’s name and address were the least useful thing on the board — a merchant knows which shop they are in — and the health panel earns the width. */}
{scope.showBranchOverview ? (

Branch performance

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

) : null}
); }