Initial commit
This commit is contained in:
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
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 { AlertTriangle, Check, ChevronDown, ChevronRight, EyeOff, Pencil } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel } from '../format';
|
||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
import { BUCKET_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||
|
||||
/**
|
||||
* Counters — the till board, written for the person who actually opens it.
|
||||
*
|
||||
* That person is a shop supervisor who knows the shop and nothing about
|
||||
* networks. The previous version of this page was accurate and unusable by
|
||||
* them: it led with a terminal code and a word like "Stale", put the money in
|
||||
* the smallest grey type at the bottom, and expected the reader to translate
|
||||
* severity into action.
|
||||
*
|
||||
* So: three plainly-named groups, one problem per card, the stake in the
|
||||
* headline, one instruction in bold, and every technical reading — version,
|
||||
* battery, storage, exact timestamps — behind a Details toggle, because that is
|
||||
* support information and support is not who is looking.
|
||||
*
|
||||
* A shop with nothing wrong sees a nearly empty page that says so. That is the
|
||||
* correct output, and it is the one the old design could not produce.
|
||||
*/
|
||||
export function TerminalsPage() {
|
||||
const { branches, scoped, selected, current } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const [showFine, setShowFine] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const labels = useCounterLabels();
|
||||
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
|
||||
const needs = board.now.length + board.look.length;
|
||||
const summary = useMemo(() => {
|
||||
if (board.isLoading && board.total === 0) return 'Checking your counters…';
|
||||
if (board.total === 0) return undefined;
|
||||
if (needs === 0) {
|
||||
return `All ${board.total} counter${board.total === 1 ? '' : 's'} are fine — everything they’ve sold has reached us. Each one is listed by the code printed on your receipts, until you rename it.`;
|
||||
}
|
||||
const stuck = board.strandedBills;
|
||||
const n = board.now.length > 0 ? board.now.length : needs;
|
||||
const head = `${n} counter${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention.`;
|
||||
const tail = ' Counters are listed by the code printed on your receipts, until you rename them.';
|
||||
return stuck > 0
|
||||
? `${head} ${stuck} sale${stuck === 1 ? '' : 's'} haven’t reached us yet.${tail}`
|
||||
: `${head}${tail}`;
|
||||
}, [board, needs]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Counters"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
{...(summary ? { description: summary } : {})}
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* A failed read is not an answer. Kept visually distinct from "this shop
|
||||
has no counters", because only one of the two means nothing is wrong. */}
|
||||
{board.failed.length > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" padding={2}>
|
||||
<span style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 2 }}>
|
||||
<AlertTriangle size={16} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
We couldn’t check {board.failed.map(branchLabel).join(', ')}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
This is a problem reaching our own system, not a problem with your counters —
|
||||
they may be trading normally.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{board.now.length > 0 ? (
|
||||
<Group bucket="now" count={board.now.length}>
|
||||
{board.now.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.look.length > 0 ? (
|
||||
<Group bucket="look" count={board.look.length}>
|
||||
{board.look.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.fine.length > 0 ? (
|
||||
<Group
|
||||
bucket="fine"
|
||||
count={board.fine.length}
|
||||
isCollapsible
|
||||
isOpen={showFine}
|
||||
onToggle={() => setShowFine((open) => !open)}
|
||||
>
|
||||
{board.fine.map((counter) => (
|
||||
<FineRow key={counter.key} counter={counter} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.total === 0 && !board.isLoading ? <Empty checked={board.checked} /> : null}
|
||||
|
||||
{labels.hiddenCount > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((open) => !open)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
fontSize: 12.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showHidden ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
Hidden counters ({labels.hiddenCount})
|
||||
</button>
|
||||
{showHidden ? <HiddenList labels={labels} /> : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Group({
|
||||
bucket,
|
||||
count,
|
||||
children,
|
||||
isCollapsible,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
bucket: Bucket;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
isCollapsible?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<HStack align="center" gap={1}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: BUCKET_COLOR[bucket],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{count}
|
||||
</Text>
|
||||
{isCollapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Hide' : 'Show'}
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</HStack>
|
||||
{!isCollapsible || isOpen ? <VStack gap={1.5}>{children}</VStack> : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The card ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One counter, one problem.
|
||||
*
|
||||
* Full width and stacked rather than in a grid: each card is a short paragraph
|
||||
* now, and a paragraph wants a line length. Three columns of prose is the
|
||||
* layout that made the last version unreadable.
|
||||
*/
|
||||
function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const { problem } = card;
|
||||
const rail = BUCKET_COLOR[problem.bucket];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ display: 'flex', minWidth: 0 }}>
|
||||
<span aria-hidden style={{ width: 3, flex: 'none', background: rail }} />
|
||||
<VStack gap={1} padding={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<CounterName card={card} labels={labels} isRenaming={isRenaming} setIsRenaming={setIsRenaming} />
|
||||
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{problem.headline}
|
||||
</Text>
|
||||
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{problem.detail}
|
||||
</Text>
|
||||
|
||||
{problem.action ? (
|
||||
<Text type="body" size="sm" weight="semibold" style={{ lineHeight: 1.6 }}>
|
||||
{problem.action}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* The reassurance. On a stuck-sales card this is the most important
|
||||
sentence on the page: it is the difference between a supervisor
|
||||
phoning head office and a supervisor checking a router. */}
|
||||
{problem.comfort ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
||||
{problem.comfort}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Everything else wrong with this counter at this urgency. One line
|
||||
each, under the instruction — the same walk fixes them, and three
|
||||
full cards with the same name at the top read as a broken page. */}
|
||||
{card.also.length > 0 ? (
|
||||
<VStack gap={0} style={{ paddingTop: 2 }}>
|
||||
{card.also.map((extra) => (
|
||||
<Text key={extra.code} type="body" size="xsm" color="secondary">
|
||||
Also: {extra.headline.toLowerCase()}
|
||||
{extra.action ? ` — ${extra.action.toLowerCase()}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
|
||||
<SmallButton
|
||||
label={showDetails ? 'Hide details' : 'Details'}
|
||||
onClick={() => setShowDetails((open) => !open)}
|
||||
/>
|
||||
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
|
||||
{problem.code === 'never-used' || problem.code === 'no-status' ? (
|
||||
<SmallButton
|
||||
label="Hide this counter"
|
||||
icon={<EyeOff size={11} />}
|
||||
onClick={() => labels.hide(card.terminalId)}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{showDetails ? <Details card={card} /> : null}
|
||||
</VStack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counter's name, and the code underneath it.
|
||||
*
|
||||
* The code stays visible even after renaming, because `models/poshealth.go`
|
||||
* calls it "the short code printed on invoices — the thing a support call
|
||||
* starts with". Someone reading a receipt needs to match it to a card.
|
||||
*/
|
||||
function CounterName({
|
||||
card,
|
||||
labels,
|
||||
isRenaming,
|
||||
setIsRenaming,
|
||||
}: {
|
||||
card: BoardCard | HealthyCounter;
|
||||
labels: CounterLabels;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (value: boolean) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => labels.nameFor(card.terminalId));
|
||||
const named = labels.isNamed(card.terminalId);
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder="Counter 2, by the door…"
|
||||
/>
|
||||
</div>
|
||||
<SmallButton
|
||||
label="Save"
|
||||
icon={<Check size={11} />}
|
||||
onClick={() => {
|
||||
labels.rename(card.terminalId, draft);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
<SmallButton label="Cancel" onClick={() => setIsRenaming(false)} />
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{card.terminalId} is the code printed on your receipts. Saved on this browser only.
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{labels.nameFor(card.terminalId)}
|
||||
</Text>
|
||||
{named && card.terminalId !== UNASSIGNED ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{card.terminalId}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(card.branchName)}
|
||||
</Text>
|
||||
{/* The "printed on your receipts" hint is NOT here. It is true of every
|
||||
unnamed counter, so putting it on every card printed it ten times down
|
||||
one page and stopped being read on the second. It is said once, under
|
||||
the page title, and again where it is actually needed — inside Rename,
|
||||
where someone is deciding what to call this thing. */}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The support view.
|
||||
*
|
||||
* Everything the previous design put on the face of the card. It is all true
|
||||
* and all useful — to whoever eventually rings the supplier, which is not the
|
||||
* person deciding whether to walk to the counter.
|
||||
*/
|
||||
function Details({ card }: { card: BoardCard }) {
|
||||
const status = card.status;
|
||||
const rows: [string, string][] = [
|
||||
['Counter code', card.terminalId],
|
||||
['Sold in this period', `${card.periodBills} · ${rupees(card.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
rows.push(['Today on the till', `${status.todayBills} · ${rupees(status.todayAmount)}`]);
|
||||
rows.push(['Last sale', clockTime(status.lastBillAt)]);
|
||||
rows.push([
|
||||
'Last heard from',
|
||||
status.receivedAt
|
||||
? `${clockTime(status.receivedAt)} (${spokenAge(status.silentForMs ?? 0)} ago)`
|
||||
: 'never',
|
||||
]);
|
||||
rows.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.appVersion) rows.push(['Till app version', status.appVersion]);
|
||||
if (status.batteryLevel !== null) {
|
||||
rows.push([
|
||||
'Battery',
|
||||
`${status.batteryLevel}%${status.batteryCharging ? ' (charging)' : ''}`,
|
||||
]);
|
||||
}
|
||||
if (status.storageFreeMb !== null) rows.push(['Free space', `${status.storageFreeMb} MB`]);
|
||||
if (status.printerReachable !== null) {
|
||||
rows.push(['Printer', status.printerReachable ? 'responding' : 'not responding']);
|
||||
}
|
||||
if (status.drawerStatus) rows.push(['Cash drawer', status.drawerStatus]);
|
||||
if (status.clockDriftSeconds !== null) {
|
||||
rows.push(['Clock difference', `${status.clockDriftSeconds}s vs our server`]);
|
||||
}
|
||||
if (status.reason) rows.push(['Reported reason', status.reason]);
|
||||
} else {
|
||||
rows.push(['Status reporting', 'this counter has never reported']);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={0} style={{ paddingTop: 4 }}>
|
||||
{rows.map(([label, value]) => (
|
||||
<HStack key={label} justify="between" align="center" gap={2} style={{ padding: '4px 0' }}>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The fine list ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** A counter with nothing wrong gets one line. Anything more is noise. */
|
||||
function FineRow({ counter, labels }: { counter: HealthyCounter; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} padding={2} wrap="wrap">
|
||||
<CounterName
|
||||
card={counter}
|
||||
labels={labels}
|
||||
isRenaming={isRenaming}
|
||||
setIsRenaming={setIsRenaming}
|
||||
/>
|
||||
{!isRenaming ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{counter.periodBills} sales · {rupees(counter.periodAmount)}
|
||||
{counter.status.lastBillAt ? ` · last sale ${clockTime(counter.status.lastBillAt)}` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hidden ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function HiddenList({ labels }: { labels: CounterLabels }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
{labels.hidden.map((terminalId) => (
|
||||
<HStack key={terminalId} justify="between" align="center" gap={2}>
|
||||
<Text type="body" size="xsm" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{labels.nameFor(terminalId)}
|
||||
</Text>
|
||||
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
|
||||
</HStack>
|
||||
))}
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
Hidden here only, on this browser. There’s no way to remove a counter from our system
|
||||
once it has been used, so this just keeps your list tidy.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Empty({ checked }: { checked: string[] }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No counter has reported yet
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
A counter appears here the first time it is used — nobody has to register one. So an
|
||||
empty list means no till has ever sent us anything, not that sales are missing.
|
||||
</Text>
|
||||
{checked.length > 0 ? (
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
Checked: {checked.map(branchLabel).join(', ')}.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SmallButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user