325 lines
11 KiB
TypeScript
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);
|
|
}
|