Files
daily_console_web/src/features/console/ConsolePage.tsx
2026-09-16 17:12:30 +05:30

182 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}