393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Activity, AlertTriangle, IndianRupee, Monitor, Search } from 'lucide-react';
|
|
import type { DateRange } from '@/api/insights';
|
|
import { useBranchScope } from '../BranchScope';
|
|
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
|
import { useCounterLabels } from '../counterLabels';
|
|
import { byUrgency, countsOf, filterRows, rowsOf, type CounterRow, type CounterState } from '../counterRows';
|
|
import { branchLabel, count, money } from '../format';
|
|
import { shortAge } from '../posStatus';
|
|
import { useTerminalBoard } from '../useTerminalBoard';
|
|
import { CounterDrawer } from '../CounterDrawer';
|
|
import '@/features/console/console.css';
|
|
import './counters.css';
|
|
|
|
/**
|
|
* Counters — every till, what it is doing, and which need somebody.
|
|
*
|
|
* ── Why this replaced the triage board ──────────────────────────────────────
|
|
*
|
|
* The old page grouped counters into "act now", "look at this" and "fine", each
|
|
* a stack of cards. That reads well with three counters and badly with fifteen:
|
|
* the groups grew into three long columns, there was no way to search for the
|
|
* code printed on a receipt, and a shop with everything working got a page of
|
|
* cards saying so.
|
|
*
|
|
* A list answers the question people actually arrive with — "what is T4289
|
|
* doing" — and the triage survives as the chips above it and the cards below.
|
|
*
|
|
* ── One page, both workspaces ───────────────────────────────────────────────
|
|
*
|
|
* Scope comes from `useBranchScope`, which pins a store user to their own
|
|
* outlet. The branch column and the branch filter simply have nothing to offer
|
|
* when there is one branch, so they are not drawn.
|
|
*/
|
|
const PAGE_SIZE = 5;
|
|
|
|
const STATE_LABEL: Record<CounterState, string> = {
|
|
active: 'Active',
|
|
attention: 'Attention',
|
|
never: 'Never used',
|
|
offline: 'Offline',
|
|
};
|
|
|
|
const STATE_TONE: Record<CounterState, string> = {
|
|
active: 'healthy',
|
|
attention: 'attention',
|
|
never: 'neutral',
|
|
offline: 'critical',
|
|
};
|
|
|
|
export function CountersPage() {
|
|
const { branches, scoped, selected, current, isPinned } = useBranchScope();
|
|
const [preset, setPreset] = useState<RangePreset>('today');
|
|
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
|
const [chip, setChip] = useState<CounterState | 'all'>('all');
|
|
const [branchId, setBranchId] = useState<number | null>(null);
|
|
const [term, setTerm] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const [open, setOpen] = useState<CounterRow | null>(null);
|
|
|
|
const labels = useCounterLabels();
|
|
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
|
|
|
const rows = useMemo(() => rowsOf(board), [board]);
|
|
const counts = useMemo(() => countsOf(rows), [rows]);
|
|
|
|
const shown = useMemo(
|
|
() => filterRows(rows, { state: chip, branchId, term, nameFor: labels.nameFor }).sort(byUrgency),
|
|
[rows, chip, branchId, term, labels],
|
|
);
|
|
|
|
// Clamped rather than reset: narrowing a filter while on page three should
|
|
// land on the last page of the new result, not silently on page one.
|
|
const pages = Math.max(1, Math.ceil(shown.length / PAGE_SIZE));
|
|
const current_ = Math.min(page, pages);
|
|
const visible = shown.slice((current_ - 1) * PAGE_SIZE, current_ * PAGE_SIZE);
|
|
|
|
const takings = rows.reduce((sum, row) => sum + row.periodAmount, 0);
|
|
const needing = counts.attention + counts.offline;
|
|
const showBranch = !isPinned && branches.length > 1;
|
|
|
|
return (
|
|
<div className="console">
|
|
<header className="console-head">
|
|
<div className="console-title">
|
|
<div className="console-title-row">
|
|
<h1>Counters</h1>
|
|
<span className="console-scope">
|
|
{selected === null
|
|
? `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}`
|
|
: branchLabel(current?.locationname) || 'This branch'}
|
|
</span>
|
|
<span className="console-live"><i />Live</span>
|
|
</div>
|
|
<p className="console-blurb">Monitor your counters, activity and connection status.</p>
|
|
</div>
|
|
<div className="console-actions">
|
|
<DateRangePicker
|
|
preset={preset}
|
|
range={range}
|
|
onChange={(nextPreset, nextRange) => {
|
|
setPreset(nextPreset);
|
|
setRange(nextRange);
|
|
}}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="kpi-strip">
|
|
<Kpi
|
|
icon={<Monitor size={17} />}
|
|
label="Total counters"
|
|
value={count(counts.all)}
|
|
note={showBranch ? `across ${branches.length} branches` : 'at this branch'}
|
|
/>
|
|
<Kpi
|
|
icon={<Activity size={17} />}
|
|
label="Active"
|
|
value={count(counts.active)}
|
|
note={counts.active === counts.all ? 'all operating normally' : 'operating normally'}
|
|
tone="healthy"
|
|
/>
|
|
<Kpi
|
|
icon={<AlertTriangle size={17} />}
|
|
label="Needs attention"
|
|
value={count(needing)}
|
|
note={needing === 0 ? 'nothing to do' : 'requires action'}
|
|
tone={needing > 0 ? 'attention' : 'healthy'}
|
|
/>
|
|
<Kpi
|
|
icon={<IndianRupee size={17} />}
|
|
label="Counter sales"
|
|
value={money(takings)}
|
|
note="in the selected period"
|
|
/>
|
|
</div>
|
|
|
|
<section className="panel">
|
|
<div className="counter-controls">
|
|
<div className="chip-row">
|
|
<Chip label="All" n={counts.all} active={chip === 'all'} onClick={() => setChip('all')} />
|
|
<Chip label="Active" n={counts.active} tone="healthy" active={chip === 'active'} onClick={() => setChip('active')} />
|
|
<Chip label="Attention" n={counts.attention} tone="attention" active={chip === 'attention'} onClick={() => setChip('attention')} />
|
|
<Chip label="Never used" n={counts.never} active={chip === 'never'} onClick={() => setChip('never')} />
|
|
<Chip label="Offline" n={counts.offline} tone="critical" active={chip === 'offline'} onClick={() => setChip('offline')} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="counter-controls">
|
|
<label className="search" style={{ flex: 1, minWidth: 240 }}>
|
|
<Search size={14} aria-hidden />
|
|
<input
|
|
value={term}
|
|
onChange={(event) => { setTerm(event.target.value); setPage(1); }}
|
|
placeholder="Search counters by code, name or branch…"
|
|
aria-label="Search counters"
|
|
/>
|
|
</label>
|
|
|
|
{showBranch ? (
|
|
<label className="picker">
|
|
<span>Branch</span>
|
|
<select
|
|
value={branchId === null ? '' : String(branchId)}
|
|
onChange={(event) => {
|
|
setBranchId(event.target.value === '' ? null : Number(event.target.value));
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<option value="">All branches</option>
|
|
{branches.map((branch) => (
|
|
<option key={branch.locationid} value={branch.locationid}>
|
|
{branchLabel(branch.locationname) || `Branch ${branch.locationid}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
|
|
{board.isLoading && rows.length === 0 ? (
|
|
<div className="panel-empty"><strong>Checking your counters…</strong></div>
|
|
) : visible.length === 0 ? (
|
|
<div className="panel-empty">
|
|
<strong>{rows.length === 0 ? 'No counters connected' : 'Nothing matches'}</strong>
|
|
<span>
|
|
{rows.length === 0
|
|
? 'A counter appears here the first time a till reports in. Until then, only app orders are counted.'
|
|
: 'Try a different code, branch or status.'}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="table-scroll">
|
|
<table className="grid-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Counter</th>
|
|
{showBranch ? <th>Branch</th> : null}
|
|
<th>Status</th>
|
|
<th>Last heard</th>
|
|
<th className="num">Period</th>
|
|
<th>Last sale</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visible.map((row) => (
|
|
<tr key={row.key}>
|
|
<td>
|
|
<div className="counter-id">
|
|
<span className="tile" data-tone="brand" aria-hidden><Monitor size={15} /></span>
|
|
<span className="counter-idtext">
|
|
<strong>{row.terminalId}</strong>
|
|
<span>{labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.branchName}</span>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
{showBranch ? <td>{row.branchName}</td> : null}
|
|
<td>
|
|
<span className="pill" data-tone={STATE_TONE[row.state]}>
|
|
<i />{STATE_LABEL[row.state]}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span className="heartbeat" data-tone={STATE_TONE[row.state]}>
|
|
<i />
|
|
{row.status?.silentForMs == null ? 'Never' : `${shortAge(row.status.silentForMs)} ago`}
|
|
</span>
|
|
</td>
|
|
<td className="num">{money(row.periodAmount)}</td>
|
|
<td>{lastSale(row)}</td>
|
|
<td>
|
|
<button type="button" className="btn-outline" onClick={() => setOpen(row)}>
|
|
View
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<footer className="panel-foot">
|
|
<span>
|
|
Showing {(current_ - 1) * PAGE_SIZE + 1} to{' '}
|
|
{Math.min(current_ * PAGE_SIZE, shown.length)} of {shown.length} counters
|
|
</span>
|
|
{pages > 1 ? (
|
|
<div className="pager">
|
|
{Array.from({ length: pages }, (_, i) => i + 1).map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
className="pager-btn"
|
|
data-active={n === current_}
|
|
onClick={() => setPage(n)}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</footer>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<NeedsAttention rows={rows} onOpen={setOpen} />
|
|
|
|
{open ? (
|
|
<CounterDrawer row={open} labels={labels} onClose={() => setOpen(null)} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Pieces ───────────────────────────────────────────────────────────────── */
|
|
|
|
function Kpi({
|
|
icon, label, value, note, tone,
|
|
}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) {
|
|
return (
|
|
<article className="kpi">
|
|
<span className="kpi-icon" data-tone={tone} aria-hidden>{icon}</span>
|
|
<div className="kpi-head">
|
|
<span className="kpi-label">{label}</span>
|
|
<span className="kpi-value">{value}</span>
|
|
<span className="kpi-note">{note}</span>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function Chip({
|
|
label, n, tone, active, onClick,
|
|
}: { label: string; n: number; tone?: string; active: boolean; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="chip"
|
|
{...(tone ? { 'data-tone': tone } : {})}
|
|
data-active={active}
|
|
onClick={onClick}
|
|
>
|
|
{label} <b>{n}</b>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The counters somebody has to walk to.
|
|
*
|
|
* Capped at three. The chips above already say how many there are, and a
|
|
* fifteen-card wall is the thing this page was redesigned away from.
|
|
*/
|
|
function NeedsAttention({
|
|
rows, onOpen,
|
|
}: {
|
|
rows: readonly CounterRow[];
|
|
onOpen: (row: CounterRow) => void;
|
|
}) {
|
|
const problems = rows.filter((row) => row.state !== 'active').sort(byUrgency);
|
|
|
|
if (problems.length === 0) {
|
|
return (
|
|
<section className="panel">
|
|
<div className="attention-clear">
|
|
<span className="tile" data-tone="healthy" aria-hidden><Activity size={16} /></span>
|
|
<div>
|
|
<strong>Every counter is reporting</strong>
|
|
<span>Everything they have sold has reached us.</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="panel">
|
|
<header className="panel-head">
|
|
<div className="attention-head">
|
|
<span className="tile" data-tone="brand" aria-hidden><AlertTriangle size={16} /></span>
|
|
<div>
|
|
<h3 className="panel-title">Needs your attention</h3>
|
|
<p className="panel-sub">These counters need somebody to look at them.</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="attention-grid counter-attention">
|
|
{problems.slice(0, 3).map((row) => (
|
|
<article key={row.key} className="attention-card" data-tone={STATE_TONE[row.state]}>
|
|
<span className="tile" data-tone={STATE_TONE[row.state]} aria-hidden><Monitor size={15} /></span>
|
|
<div className="attention-text">
|
|
<span className="attention-title">
|
|
<strong>{row.terminalId}</strong>{' '}
|
|
<span className="pill" data-tone={STATE_TONE[row.state]}><i />{STATE_LABEL[row.state]}</span>
|
|
</span>
|
|
<span className="attention-detail">{reasonFor(row)}</span>
|
|
<span className="attention-branch">{row.branchName}</span>
|
|
</div>
|
|
<button type="button" className="btn-outline" onClick={() => onOpen(row)}>
|
|
View counter
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** What a person needs to know before walking over. */
|
|
function reasonFor(row: CounterRow): string {
|
|
if (row.state === 'never') return 'This counter has never recorded a sale.';
|
|
if (row.state === 'offline') {
|
|
return row.status?.silentForMs == null
|
|
? 'No heartbeat received.'
|
|
: `Last heard ${shortAge(row.status.silentForMs)} ago.`;
|
|
}
|
|
if (row.status && row.status.pendingBills > 0) {
|
|
const n = row.status.pendingBills;
|
|
return `${n} bill${n === 1 ? '' : 's'} taken here have not reached the books.`;
|
|
}
|
|
return row.card?.problem.detail ?? 'Reporting late.';
|
|
}
|
|
|
|
function lastSale(row: CounterRow): string {
|
|
const at = row.status?.lastBillAt;
|
|
if (!at) return '—';
|
|
return `${shortAge(Date.now() - at.getTime())} ago`;
|
|
}
|