diff --git a/src/App.tsx b/src/App.tsx
index 4b82c53..d064b2d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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'));
diff --git a/src/features/store-admin/CounterDrawer.tsx b/src/features/store-admin/CounterDrawer.tsx
new file mode 100644
index 0000000..924cfbe
--- /dev/null
+++ b/src/features/store-admin/CounterDrawer.tsx
@@ -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 (
+
+
+
+ {readings.map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+
+
+
+ }
+ onClick={() => {
+ labels.hide(row.terminalId);
+ onClose();
+ }}
+ />
+
+
+ Hiding removes a counter from this page. It keeps recording sales — use it for a till
+ that has been retired.
+
+
+
+
+ );
+}
diff --git a/src/features/store-admin/counterRows.test.ts b/src/features/store-admin/counterRows.test.ts
new file mode 100644
index 0000000..5df5b77
--- /dev/null
+++ b/src/features/store-admin/counterRows.test.ts
@@ -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 =>
+ ({
+ 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 => ({
+ 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']);
+});
diff --git a/src/features/store-admin/counterRows.ts b/src/features/store-admin/counterRows.ts
new file mode 100644
index 0000000..39cca85
--- /dev/null
+++ b/src/features/store-admin/counterRows.ts
@@ -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 = { offline: 0, attention: 1, never: 2, active: 3 };
+ return rank[a.state] - rank[b.state];
+}
diff --git a/src/features/store-admin/pages/CountersPage.tsx b/src/features/store-admin/pages/CountersPage.tsx
new file mode 100644
index 0000000..9840b45
--- /dev/null
+++ b/src/features/store-admin/pages/CountersPage.tsx
@@ -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 = {
+ active: 'Active',
+ attention: 'Attention',
+ never: 'Never used',
+ offline: 'Offline',
+};
+
+const STATE_TONE: Record = {
+ active: 'healthy',
+ attention: 'attention',
+ never: 'neutral',
+ offline: 'critical',
+};
+
+export function CountersPage() {
+ const { branches, scoped, selected, current, isPinned } = useBranchScope();
+ const [preset, setPreset] = useState('today');
+ const [range, setRange] = useState(() => presetRange('today'));
+ const [chip, setChip] = useState('all');
+ const [branchId, setBranchId] = useState(null);
+ const [term, setTerm] = useState('');
+ const [page, setPage] = useState(1);
+ const [open, setOpen] = useState(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 (
+
+ {rows.length === 0 ? 'No counters connected' : 'Nothing matches'}
+
+ {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.'}
+
+