terminal redesign
This commit is contained in:
@@ -43,7 +43,9 @@ const InventoryPage = named('InventoryPage', () => import('@/features/store-admi
|
||||
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
|
||||
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
|
||||
const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage'));
|
||||
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
|
||||
/* The counters board. Both workspaces render it; it reads its own scope from
|
||||
BranchScope, which pins a store user to their own outlet. */
|
||||
const TerminalsPage = named('CountersPage', () => import('@/features/store-admin/pages/CountersPage'));
|
||||
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
|
||||
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
|
||||
const OnboardingPage = named('OnboardingPage', () => import('@/features/onboarding/OnboardingPage'));
|
||||
|
||||
110
src/features/store-admin/CounterDrawer.tsx
Normal file
110
src/features/store-admin/CounterDrawer.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { EyeOff } from 'lucide-react';
|
||||
import { Drawer } from './Drawer';
|
||||
import type { CounterLabels } from './counterLabels';
|
||||
import type { CounterRow } from './counterRows';
|
||||
import { money } from './format';
|
||||
import { shortAge } from './posStatus';
|
||||
|
||||
/**
|
||||
* One counter, in full.
|
||||
*
|
||||
* The list answers "which counters need me"; this answers "what is this one
|
||||
* doing" — the readings a person wants before walking over to it, plus the two
|
||||
* things they can change about it.
|
||||
*
|
||||
* Rename and hide live here rather than on the row. They are rare actions, and
|
||||
* a control on every row of a fifteen-row table is fifteen chances to hide a
|
||||
* counter by mistake.
|
||||
*/
|
||||
export interface CounterDrawerProps {
|
||||
row: CounterRow;
|
||||
labels: CounterLabels;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
|
||||
);
|
||||
const status = row.status;
|
||||
|
||||
const readings: [string, string][] = [
|
||||
['Counter code', row.terminalId],
|
||||
['Branch', row.branchName],
|
||||
['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]);
|
||||
readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']);
|
||||
readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]);
|
||||
// The number that explains a revenue figure reading low, so it is stated
|
||||
// even when it is zero rather than hidden as "nothing to report".
|
||||
readings.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.reason) readings.push(['Reported', status.reason]);
|
||||
} else {
|
||||
readings.push(['Status', 'This counter has never reported in.']);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId}
|
||||
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName}
|
||||
width={460}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
<div className="reading-list">
|
||||
{readings.map(([label, value]) => (
|
||||
<div key={label} className="reading">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={row.terminalId}
|
||||
description="Shown instead of the code. The code stays on the receipts."
|
||||
/>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button
|
||||
label="Hide this counter"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<EyeOff size={14} />}
|
||||
onClick={() => {
|
||||
labels.hide(row.terminalId);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save name"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
|
||||
onClick={() => {
|
||||
labels.rename(row.terminalId, draft.trim());
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Hiding removes a counter from this page. It keeps recording sales — use it for a till
|
||||
that has been retired.
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
99
src/features/store-admin/counterRows.test.ts
Normal file
99
src/features/store-admin/counterRows.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import type { TerminalStatus } from './posStatus';
|
||||
import { byUrgency, countsOf, filterRows, stateOf, type CounterRow } from './counterRows';
|
||||
|
||||
const status = (over: Partial<TerminalStatus> = {}): TerminalStatus =>
|
||||
({
|
||||
terminalId: 'T1', storeName: 'R mart', presence: 'online', sync: 'synced', state: 'synced',
|
||||
pendingBills: 0, oldestPendingMs: 0, oldestPendingAt: null, silentForMs: 30_000,
|
||||
todayBills: 4, todayAmount: 450, lastBillAt: new Date(), reportedAt: null, receivedAt: null,
|
||||
clockDriftSeconds: null, reason: '',
|
||||
...over,
|
||||
}) as TerminalStatus;
|
||||
|
||||
/*
|
||||
Four states, and the one that matters is the split between "never used" and
|
||||
"offline". Read only the heartbeat and they look identical — but one is a
|
||||
counter nobody has switched on yet, and the other is a shop losing sales right
|
||||
now. The old page put them in one bucket, which diluted the urgent case with
|
||||
the routine one.
|
||||
*/
|
||||
|
||||
test('a counter that has never sold and never reported is "never used"', () => {
|
||||
assert.equal(stateOf(status({ lastBillAt: null, todayBills: 0, silentForMs: null }), false), 'never');
|
||||
});
|
||||
|
||||
test('a counter that HAS sold before but is silent now is offline, not "never used"', () => {
|
||||
// The distinction the page exists to make.
|
||||
assert.equal(
|
||||
stateOf(status({ state: 'offline', lastBillAt: new Date('2026-09-01'), todayBills: 0 }), false),
|
||||
'offline',
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale counter counts as offline', () => {
|
||||
// Its last word was "fine" and everything since is guesswork.
|
||||
assert.equal(stateOf(status({ state: 'stale' }), false), 'offline');
|
||||
});
|
||||
|
||||
test('unsent bills put a healthy counter into attention', () => {
|
||||
// Connected and holding sales is exactly what makes revenue read low, and it
|
||||
// is invisible if only the connection state is read.
|
||||
assert.equal(stateOf(status({ pendingBills: 3 }), false), 'attention');
|
||||
});
|
||||
|
||||
test('a named problem puts a counter into attention even when it looks fine', () => {
|
||||
assert.equal(stateOf(status(), true), 'attention');
|
||||
});
|
||||
|
||||
test('a counter with no status at all is "never used", not a crash', () => {
|
||||
assert.equal(stateOf(null, false), 'never');
|
||||
});
|
||||
|
||||
/* ── Counting and filtering ───────────────────────────────────────────────── */
|
||||
|
||||
const row = (key: string, over: Partial<CounterRow> = {}): CounterRow => ({
|
||||
key, terminalId: key, branchId: 1, branchName: 'R mart', state: 'active',
|
||||
status: status(), periodBills: 0, periodAmount: 0, card: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
test('every counter is counted exactly once', () => {
|
||||
const counts = countsOf([
|
||||
row('a'), row('b'), row('c', { state: 'attention' }), row('d', { state: 'offline' }),
|
||||
]);
|
||||
assert.deepEqual(counts, { all: 4, active: 2, attention: 1, never: 0, offline: 1 });
|
||||
});
|
||||
|
||||
test('search finds a counter by the code printed on its receipts', () => {
|
||||
// Usually the only identifier the person standing at it can read.
|
||||
const rows = [row('T5ED0'), row('T8719')];
|
||||
const found = filterRows(rows, { state: 'all', branchId: null, term: 't5ed', nameFor: () => '' });
|
||||
assert.equal(found.length, 1);
|
||||
assert.equal(found[0]?.terminalId, 'T5ED0');
|
||||
});
|
||||
|
||||
test('search also finds it by the name the operator gave it', () => {
|
||||
const rows = [row('T5ED0'), row('T8719')];
|
||||
const found = filterRows(rows, {
|
||||
state: 'all', branchId: null, term: 'main',
|
||||
nameFor: (id) => (id === 'T5ED0' ? 'Main Counter' : 'Counter 2'),
|
||||
});
|
||||
assert.equal(found[0]?.terminalId, 'T5ED0');
|
||||
});
|
||||
|
||||
test('a branch filter and a state filter both apply', () => {
|
||||
const rows = [
|
||||
row('a', { branchId: 1, state: 'offline' }),
|
||||
row('b', { branchId: 2, state: 'offline' }),
|
||||
row('c', { branchId: 1, state: 'active' }),
|
||||
];
|
||||
const found = filterRows(rows, { state: 'offline', branchId: 1, term: '', nameFor: () => '' });
|
||||
assert.deepEqual(found.map((r) => r.key), ['a']);
|
||||
});
|
||||
|
||||
test('the worst counters sort to the top', () => {
|
||||
const sorted = [row('a'), row('b', { state: 'offline' }), row('c', { state: 'attention' })].sort(byUrgency);
|
||||
assert.deepEqual(sorted.map((r) => r.state), ['offline', 'attention', 'active']);
|
||||
});
|
||||
126
src/features/store-admin/counterRows.ts
Normal file
126
src/features/store-admin/counterRows.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Board, BoardCard, HealthyCounter } from './useTerminalBoard';
|
||||
import type { TerminalStatus } from './posStatus';
|
||||
|
||||
/**
|
||||
* The board, flattened into one list a table can show.
|
||||
*
|
||||
* `useTerminalBoard` groups counters by urgency — act now, look at it, fine —
|
||||
* which is the right shape for a triage board and the wrong one for a list you
|
||||
* filter and page through. Both views want the same underlying rows, so the
|
||||
* grouping is unwound here rather than duplicated in a second hook.
|
||||
*
|
||||
* Pure, so the state a chip filters on can be tested without a POS.
|
||||
*/
|
||||
|
||||
export type CounterState = 'active' | 'attention' | 'offline' | 'never';
|
||||
|
||||
export interface CounterRow {
|
||||
key: string;
|
||||
terminalId: string;
|
||||
branchId: number;
|
||||
branchName: string;
|
||||
state: CounterState;
|
||||
status: TerminalStatus | null;
|
||||
periodBills: number;
|
||||
periodAmount: number;
|
||||
/** The card behind it, when there is a problem worth naming. */
|
||||
card: BoardCard | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the four states a counter is in.
|
||||
*
|
||||
* "Never used" is separated from "offline" deliberately, and it is the
|
||||
* distinction the old page could not make: a counter that has never sold
|
||||
* anything looks identical to one that died this morning if you only read the
|
||||
* heartbeat. One is a counter nobody has switched on yet — a setup step — and
|
||||
* the other is a shop losing sales right now. Putting them in one bucket meant
|
||||
* the urgent case was diluted by the routine one.
|
||||
*/
|
||||
export function stateOf(status: TerminalStatus | null, hasProblem: boolean): CounterState {
|
||||
if (!status) return 'never';
|
||||
|
||||
const neverSold = status.lastBillAt === null && status.todayBills === 0;
|
||||
const neverHeard = status.silentForMs === null;
|
||||
|
||||
if (neverSold && neverHeard) return 'never';
|
||||
if (status.state === 'offline' || status.state === 'stale') return 'offline';
|
||||
if (hasProblem || status.state === 'delayed' || status.pendingBills > 0) return 'attention';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export function rowsOf(board: Board): CounterRow[] {
|
||||
const fromCard = (card: BoardCard): CounterRow => ({
|
||||
key: card.key,
|
||||
terminalId: card.terminalId,
|
||||
branchId: card.branchId,
|
||||
branchName: card.branchName,
|
||||
state: stateOf(card.status, true),
|
||||
status: card.status,
|
||||
periodBills: card.periodBills,
|
||||
periodAmount: card.periodAmount,
|
||||
card,
|
||||
});
|
||||
|
||||
const fromHealthy = (counter: HealthyCounter): CounterRow => ({
|
||||
key: counter.key,
|
||||
terminalId: counter.terminalId,
|
||||
branchId: counter.branchId,
|
||||
branchName: counter.branchName,
|
||||
state: stateOf(counter.status, false),
|
||||
status: counter.status,
|
||||
periodBills: counter.periodBills,
|
||||
periodAmount: counter.periodAmount,
|
||||
card: null,
|
||||
});
|
||||
|
||||
return [
|
||||
...board.now.map(fromCard),
|
||||
...board.look.map(fromCard),
|
||||
...board.fine.map(fromHealthy),
|
||||
];
|
||||
}
|
||||
|
||||
export interface CounterCounts {
|
||||
all: number;
|
||||
active: number;
|
||||
attention: number;
|
||||
never: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
export function countsOf(rows: readonly CounterRow[]): CounterCounts {
|
||||
const counts: CounterCounts = { all: rows.length, active: 0, attention: 0, never: 0, offline: 0 };
|
||||
for (const row of rows) counts[row.state] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a filter and a search leave behind.
|
||||
*
|
||||
* Search covers the code, the given name and the branch, because those are the
|
||||
* three things somebody standing at a counter can read off it — and the code on
|
||||
* the receipt is usually the only one they have.
|
||||
*/
|
||||
export function filterRows(
|
||||
rows: readonly CounterRow[],
|
||||
options: { state: CounterState | 'all'; branchId: number | null; term: string; nameFor: (id: string) => string },
|
||||
): CounterRow[] {
|
||||
const needle = options.term.trim().toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
if (options.state !== 'all' && row.state !== options.state) return false;
|
||||
if (options.branchId !== null && row.branchId !== options.branchId) return false;
|
||||
if (needle === '') return true;
|
||||
return (
|
||||
row.terminalId.toLowerCase().includes(needle) ||
|
||||
row.branchName.toLowerCase().includes(needle) ||
|
||||
options.nameFor(row.terminalId).toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Worst first, so the reason to open the page is at the top of it. */
|
||||
export function byUrgency(a: CounterRow, b: CounterRow): number {
|
||||
const rank: Record<CounterState, number> = { offline: 0, attention: 1, never: 2, active: 3 };
|
||||
return rank[a.state] - rank[b.state];
|
||||
}
|
||||
392
src/features/store-admin/pages/CountersPage.tsx
Normal file
392
src/features/store-admin/pages/CountersPage.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
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`;
|
||||
}
|
||||
111
src/features/store-admin/pages/counters.css
Normal file
111
src/features/store-admin/pages/counters.css
Normal file
@@ -0,0 +1,111 @@
|
||||
/* ══ Counters ══════════════════════════════════════════════════════════════
|
||||
Everything here builds on the Console's tokens and components — .panel,
|
||||
.kpi, .chip, .pill, .grid-table, .tile — so the two pages read as one
|
||||
product. Only what is particular to this page is defined. */
|
||||
|
||||
.counter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* The KPI icon takes a state tint here, unlike the Console's, because these
|
||||
four cards ARE the state: "Active 12" and "Needs attention 2" are the answer,
|
||||
not a category. */
|
||||
.kpi-icon[data-tone="healthy"] { background: #eef8f2; color: #1c6b47; }
|
||||
.kpi-icon[data-tone="attention"] { background: #fdf6e8; color: #8a5a00; }
|
||||
.kpi-icon[data-tone="critical"] { background: #fdefee; color: #b3261e; }
|
||||
|
||||
/* A counter with no name yet still needs two lines, so the rows do not jump
|
||||
in height as names are given. */
|
||||
.counter-id { display: flex; align-items: center; gap: 10px; }
|
||||
.counter-idtext { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.counter-idtext strong { font: 600 13px/1.25 var(--font-sans); color: var(--color-ink-1); }
|
||||
.counter-idtext span { font: 400 11.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
/* "Never used" is neither good nor bad — it is a setup step nobody has taken.
|
||||
Colouring it amber would put it in the same bucket as a till that died this
|
||||
morning, which is the distinction this page exists to make. */
|
||||
.pill[data-tone="neutral"] {
|
||||
background: var(--color-surface-subtle);
|
||||
color: var(--color-ink-2);
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
.heartbeat[data-tone="neutral"] i { background: var(--color-ink-4); }
|
||||
|
||||
.picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.picker > span {
|
||||
font: 400 12px/1 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.picker select {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: none;
|
||||
font: 500 13px/1 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
cursor: pointer;
|
||||
max-width: 180px;
|
||||
}
|
||||
.picker:focus-within { border-color: var(--color-brand); }
|
||||
|
||||
/* ── Pagination ─────────────────────────────────────────────────────────── */
|
||||
.pager { display: inline-flex; gap: 6px; }
|
||||
.pager-btn {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-ink-2);
|
||||
font: 500 12.5px/1 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pager-btn[data-active="true"] {
|
||||
border-color: var(--color-brand);
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
.pager-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
/* ── Attention cards ────────────────────────────────────────────────────── */
|
||||
/* Three across rather than the Console's two: these carry a code and one
|
||||
sentence, where the Console's carry a branch name and an explanation. */
|
||||
@media (min-width: 1100px) {
|
||||
.counter-attention { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
.counter-attention .attention-card { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.attention-branch { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-4); }
|
||||
.attention-title .pill { margin-left: 6px; }
|
||||
|
||||
/* ── Drawer readings ────────────────────────────────────────────────────── */
|
||||
.reading-list { display: flex; flex-direction: column; }
|
||||
.reading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.reading:last-child { border-bottom: none; }
|
||||
.reading span { font: 400 12.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||
.reading strong {
|
||||
font: 500 13px/1.3 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
Reference in New Issue
Block a user