Initial commit
This commit is contained in:
272
src/features/store-admin/useTerminalBoard.ts
Normal file
272
src/features/store-admin/useTerminalBoard.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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;
|
||||
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);
|
||||
|
||||
return {
|
||||
now: inBucket('now'),
|
||||
look: inBucket('look'),
|
||||
fine,
|
||||
total,
|
||||
strandedBills,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user