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, updatedAt: number): ReadonlySet { const previous = useRef>(new Set()); const snapshot = useRef>(new Set()); 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(); 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(); 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); }