182 lines
7.2 KiB
TypeScript
182 lines
7.2 KiB
TypeScript
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<BranchRow[]>(
|
||
() =>
|
||
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 (
|
||
<div className="console">
|
||
<h1 className="sr-only">Console</h1>
|
||
|
||
<KpiStrip totals={totals} isLoading={isBusy} />
|
||
|
||
{/* 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. */}
|
||
<SalesOverview days={series.days} isLoading={isBusy} />
|
||
|
||
{/* 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. */}
|
||
<div className="panel-pair">
|
||
<StoreHealth
|
||
rows={rows}
|
||
totals={totals}
|
||
isAggregate={scope.isAggregate}
|
||
productCount={(products.data ?? []).length}
|
||
base={base}
|
||
/>
|
||
<QuickActions 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>
|
||
) : null}
|
||
|
||
<TillSync rows={rows} showBranch={scope.showBranchColumn} base={base} />
|
||
|
||
<NeedsAttention alerts={alerts} isAggregate={scope.isAggregate} base={base} />
|
||
</div>
|
||
);
|
||
}
|