Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View 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>
);
}