Files
daily_console_web/src/features/store-admin/useTerminalBoard.ts
2026-08-27 13:58:19 +05:30

325 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react';
import type { DateRange } from '@/api/insights';
import type { TenantLocation } from '@/api/types';
import { usePosHealthByBranch, usePosSalesByBranch } from '@/queries/hooks';
import { readTerminal, readTerminalId, type TerminalStatus } from './posStatus';
import {
BUCKET_RANK,
noStatusProblem,
problemsFor,
type Bucket,
type Problem,
type ProblemContext,
} from './terminalProblems';
/**
* One card on the board: a COUNTER, within one bucket.
*
* The split is by urgency, not by problem. A till that is out of paper AND low
* on space AND on a flat battery is one counter someone walks to once — three
* consecutive cards with the same name at the top read as a fault in the page,
* not as three things to do.
*
* But a counter that is BOTH holding unsent sales and low on storage appears
* twice, once in each bucket, because those are genuinely different errands
* with different urgency. `problem` is the one that decides the headline and
* the instruction; `also` is everything else in the same bucket, listed short.
*/
export interface BoardCard {
key: string;
branchId: number;
branchName: string;
terminalId: string;
problem: Problem;
also: Problem[];
status: TerminalStatus | null;
/** Trading in the selected period, from the sales split. */
periodBills: number;
periodAmount: number;
}
/** A counter with nothing wrong. One line, no card. */
export interface HealthyCounter {
key: string;
branchId: number;
branchName: string;
terminalId: string;
status: TerminalStatus;
periodBills: number;
periodAmount: number;
}
export interface Board {
now: BoardCard[];
look: BoardCard[];
fine: HealthyCounter[];
/** Every counter id in scope, including hidden ones. */
total: number;
/**
* Sales sitting on counters we CANNOT currently see.
*
* Not every unsent sale. A till that is online with three bills in flight is
* sending them right now and will be done before anyone reads this; counting
* those in the page headline turns a normal second into an alarm, and a
* headline that cries wolf is the one thing this page cannot afford.
*/
strandedBills: number;
/**
* What every counter in scope rang in the selected period.
*
* From the sales split, not from the heartbeats: a till reports its own
* running total, and two tills whose clocks disagree would otherwise be added
* together into a figure the books never saw.
*
* There is deliberately NO "amount stuck" twin. The health record carries
* `pending_bills` — a count — and no value, so the money sitting on an
* unreachable till is a number this backend cannot tell us. The page says how
* many sales are waiting and stops there rather than estimating.
*/
takenAmount: number;
takenBills: number;
/**
* How many COUNTERS need attention — not how many cards are on screen.
*
* A counter with a problem in both buckets produces two cards, so counting
* cards against `total` reads "5 of 4 counters". Distinct terminals is the
* only version of this number that can be shown beside a total.
*/
needCounters: number;
isLoading: boolean;
/** Branches whose health read FAILED — not branches with no counters. */
failed: string[];
/** Names of every branch actually asked about, for the empty state. */
checked: string[];
}
/**
* Remember which counters were stale on the PREVIOUS read.
*
* Hysteresis. The till heartbeats every 30s, so one on a weak connection
* crosses the staleness line and comes back on alternate reads. Requiring two
* consecutive misses costs one refresh of latency and buys a board that does
* not flicker — and a board that flickers is one people learn to ignore, which
* is the failure mode that actually matters.
*
* Keyed on the query's `dataUpdatedAt` rather than the data, which gets a fresh
* identity each poll whether or not anything changed.
*/
function usePreviousStale(current: ReadonlySet<string>, updatedAt: number): ReadonlySet<string> {
const previous = useRef<ReadonlySet<string>>(new Set<string>());
const snapshot = useRef<ReadonlySet<string>>(new Set<string>());
const seenAt = useRef(0);
useEffect(() => {
if (updatedAt === seenAt.current) return;
seenAt.current = updatedAt;
previous.current = snapshot.current;
snapshot.current = current;
}, [updatedAt, current]);
return previous.current;
}
export interface BoardOptions {
branches: readonly TenantLocation[];
range: DateRange;
/** Counters the operator has hidden. See `counterLabels.ts`. */
isHidden: (terminalId: string) => boolean;
}
/**
* Assemble the board.
*
* Two reads per branch — health and the sales split — because the second is the
* only way to find a counter that is billing while reporting nothing. Both fan
* out per location: there is no tenant-wide POS read in Fiesta.
*/
export function useTerminalBoard({ branches, range, isHidden }: BoardOptions): Board {
const branchIds = useMemo(() => branches.map((branch) => branch.locationid), [branches]);
const health = usePosHealthByBranch(branchIds);
const sales = usePosSalesByBranch(branchIds, range);
const now = Date.now();
const updatedAt = health.reduce((max, query) => Math.max(max, query.dataUpdatedAt ?? 0), 0);
const read = useMemo(
() =>
branches.map((branch, index) => ({
branch,
query: health[index],
terminals: (health[index]?.data ?? []).map((raw) => readTerminal(raw, now)),
})),
// `now` is excluded deliberately: including it re-derives the whole board
// on every render rather than on every read, and the ages on screen do not
// need sub-poll precision.
// eslint-disable-next-line react-hooks/exhaustive-deps
[branches, health, updatedAt],
);
const currentlyStale = useMemo(
() =>
new Set(
read.flatMap(({ terminals }) =>
terminals.filter((t) => t.presence === 'stale').map((t) => t.terminalId),
),
),
[read],
);
const wasStale = usePreviousStale(currentlyStale, updatedAt);
return useMemo(() => {
const cards: BoardCard[] = [];
const fine: HealthyCounter[] = [];
const failed: string[] = [];
let total = 0;
let strandedBills = 0;
read.forEach(({ branch, query, terminals }, index) => {
if (query?.isError) failed.push(branch.locationname);
const split = sales[index]?.data?.byterminal ?? [];
// `terminalid`, not `terminal_id` — the sales and presence payloads
// disagree on the spelling. See `PosSalesSummary` in `api/types.ts`.
const byId = new Map(
split.map((entry) => [readTerminalId(entry.terminalid), entry] as const),
);
const context: ProblemContext = {
now,
wasStale,
...(branch.opentime ? { opentime: branch.opentime } : {}),
...(branch.closetime ? { closetime: branch.closetime } : {}),
};
const seen = new Set<string>();
for (const status of terminals) {
seen.add(status.terminalId);
if (isHidden(status.terminalId)) continue;
total += 1;
if (status.presence !== 'online') strandedBills += status.pendingBills;
const sale = byId.get(status.terminalId);
const periodBills = sale?.billcount ?? 0;
const periodAmount = sale?.amount ?? 0;
const problems = problemsFor(status, context);
if (problems.length === 0) {
fine.push({
key: `${branch.locationid}:${status.terminalId}`,
branchId: branch.locationid,
branchName: branch.locationname,
terminalId: status.terminalId,
status,
periodBills,
periodAmount,
});
continue;
}
// One card per bucket this counter has problems in, not one per
// problem. `problemsFor` already returns worst first, so the first in
// each bucket is the one that earns the headline.
for (const bucket of ['now', 'look'] as const) {
const inBucket = problems.filter((problem) => problem.bucket === bucket);
const [primary, ...also] = inBucket;
if (!primary) continue;
cards.push({
key: `${branch.locationid}:${status.terminalId}:${bucket}`,
branchId: branch.locationid,
branchName: branch.locationname,
terminalId: status.terminalId,
problem: primary,
also,
status,
periodBills,
periodAmount,
});
}
}
// Counters known only from the sales figures. They have no presence
// record to build a card from, so without this they are invisible here
// while sitting plainly in the day's takings.
for (const entry of split) {
const id = readTerminalId(entry.terminalid);
if (seen.has(id) || isHidden(id)) continue;
total += 1;
const problem = noStatusProblem(entry.billcount ?? 0, entry.amount ?? 0);
cards.push({
key: `${branch.locationid}:${id}:${problem.bucket}`,
branchId: branch.locationid,
branchName: branch.locationname,
terminalId: id,
problem,
also: [],
status: null,
periodBills: entry.billcount ?? 0,
periodAmount: entry.amount ?? 0,
});
}
});
cards.sort(compareCards);
fine.sort((a, b) => b.periodAmount - a.periodAmount);
const inBucket = (bucket: Bucket) => cards.filter((card) => card.problem.bucket === bucket);
// Summed per COUNTER, not per card. A counter with a problem in both
// buckets produces two cards carrying the same period figures, so adding
// the cards up would bill it twice — and it would be the counters in the
// worst shape that got double-counted, which is the wrong direction for a
// number the page leads with.
const takings = new Map<string, { bills: number; amount: number }>();
for (const card of cards) {
takings.set(`${card.branchId}:${card.terminalId}`, {
bills: card.periodBills,
amount: card.periodAmount,
});
}
for (const counter of fine) {
takings.set(`${counter.branchId}:${counter.terminalId}`, {
bills: counter.periodBills,
amount: counter.periodAmount,
});
}
const needing = new Set(cards.map((card) => `${card.branchId}:${card.terminalId}`));
let takenBills = 0;
let takenAmount = 0;
for (const entry of takings.values()) {
takenBills += entry.bills;
takenAmount += entry.amount;
}
return {
now: inBucket('now'),
look: inBucket('look'),
fine,
total,
strandedBills,
takenAmount,
takenBills,
needCounters: needing.size,
isLoading: health.some((query) => query.isLoading),
failed,
checked: branches.map((branch) => branch.locationname),
};
}, [read, sales, wasStale, health, branches, isHidden, now]);
}
/**
* Worst first, then longest-running, then by counter.
*
* Not alphabetical, and not grouped by branch. Noticing should not require
* scanning: the counter that needs someone is the one the eye lands on, without
* being looked for. The branch is named on every card instead.
*/
function compareCards(a: BoardCard, b: BoardCard): number {
const bucket = BUCKET_RANK[b.problem.bucket] - BUCKET_RANK[a.problem.bucket];
if (bucket !== 0) return bucket;
const duration = (b.problem.forMs ?? 0) - (a.problem.forMs ?? 0);
if (duration !== 0) return duration;
return a.terminalId.localeCompare(b.terminalId);
}