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} +
+ ))} +
+ + + + + + ))} + + ) : null} + + + )} + + + + + {open ? ( + setOpen(null)} /> + ) : null} + + ); +} + +/* ── Pieces ───────────────────────────────────────────────────────────────── */ + +function Kpi({ + icon, label, value, note, tone, +}: { icon: React.ReactNode; label: string; value: string; note: string; tone?: string }) { + return ( +
+ {icon} +
+ {label} + {value} + {note} +
+
+ ); +} + +function Chip({ + label, n, tone, active, onClick, +}: { label: string; n: number; tone?: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +/** + * 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 ( +
+
+ +
+ Every counter is reporting + Everything they have sold has reached us. +
+
+
+ ); + } + + return ( +
+
+
+ +
+

Needs your attention

+

These counters need somebody to look at them.

+
+
+
+ +
+ {problems.slice(0, 3).map((row) => ( +
+ +
+ + {row.terminalId}{' '} + {STATE_LABEL[row.state]} + + {reasonFor(row)} + {row.branchName} +
+ +
+ ))} +
+
+ ); +} + +/** 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`; +} diff --git a/src/features/store-admin/pages/counters.css b/src/features/store-admin/pages/counters.css new file mode 100644 index 0000000..f06dffc --- /dev/null +++ b/src/features/store-admin/pages/counters.css @@ -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; +}