Files
daily_console_web/src/features/store-admin/pages/TerminalsPage.tsx
2026-09-07 11:00:44 +05:30

748 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
EyeOff,
Monitor,
Pencil,
Send,
} from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
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_LABEL, rupees, type Bucket } from '../terminalProblems';
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
/**
* 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;
/**
* The header line, now that the tiles carry the counts.
*
* It used to lead with "2 counters need attention. 41 sales haven't reached
* us yet" — which the KPI row above now states in larger type, so keeping it
* here printed the same two numbers twice, ten pixels apart. What is left is
* the one thing no tile can say: why every counter is called T4A9.
*/
const summary = useMemo(() => {
if (board.isLoading && board.total === 0) return 'Checking your counters…';
if (board.total === 0) return undefined;
if (needs === 0) {
return 'Everything they’ve sold has reached us. Each counter is listed by the code printed on your receipts, until you rename it.';
}
return 'Counters are listed by the code printed on your receipts, until you rename them.';
}, [board.isLoading, board.total, 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);
}}
/>
}
/>
{/* The console's opening move on every operations page — Console, Sales,
Inventory, Reports all lead with this same grid. This page used to
open with a sentence instead, which is most of why it read as a
bolt-on rather than part of the product.
It also puts the stake where the eye lands. The sales waiting on an
unreachable till were previously a clause in a paragraph, and they are
the reason anybody opens this page. */}
{board.total > 0 ? (
<div className="kpi-grid">
<KpiCard
label="Not reached"
value={String(board.strandedBills)}
note={board.strandedBills > 0 ? 'sales we can’t see' : 'everything is in'}
tone={board.strandedBills > 0 ? 'error' : 'neutral'}
icon={<Send size={15} />}
/>
<KpiCard
label="Need someone"
value={String(board.needCounters)}
note={`of ${board.total}`}
tone={board.needCounters > 0 ? 'warning' : 'neutral'}
icon={<AlertTriangle size={15} />}
/>
<KpiCard
label="Selling fine"
value={String(board.fine.length)}
note={`${rupees(board.takenAmount)} taken`}
tone="accent"
icon={<Monitor size={15} />}
/>
</div>
) : null}
{/* 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} variant="transparent">
<HStack gap={1.5} align="start" style={{ ...panelStyle, padding: 16 }}>
<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)}
>
<FineTable counters={board.fine} 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>
);
}
/**
* The page's one surface.
*
* A hairline and a radius, no fill. Every block here used to be a white
* `elevation="low"` card, which on a tinted page reads as a stack of raised
* slabs — and the console has been moving away from that everywhere else.
* Defined once so the edges cannot drift apart the next time one of these is
* edited.
*/
const panelStyle: React.CSSProperties = {
borderRadius: 14,
border: '1px solid var(--color-line)',
};
/* ── 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}>
{/* `SectionHeader`, like every other section in the console. This used to
be a hand-built row — an 8px coloured dot, an uppercase micro-label and
a bare number — which is a heading style that exists nowhere else in
the product. The colour went with it: severity is already carried by
the wording and by the order the groups appear in, and saying it a
third time in red was the page shouting. */}
<SectionHeader
title={BUCKET_LABEL[bucket]}
note={String(count)}
{...(isCollapsible
? {
action: (
<Button
label={isOpen ? 'Hide' : 'Show'}
variant="ghost"
size="sm"
icon={isOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
onClick={onToggle ?? (() => {})}
/>
),
}
: {})}
/>
{!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;
return (
<Card padding={0} variant="transparent">
<div
style={{ ...panelStyle, display: 'flex', gap: 14, minWidth: 0, padding: 16 }}
>
{/* The tinted icon tile `KpiCard` uses, in place of the 3px coloured
rail this card used to carry. The rail existed nowhere else in the
console; the tile is the shape the product already uses to mark
what a block is about. */}
<span
aria-hidden
style={{
display: 'grid',
placeItems: 'center',
width: 34,
height: 34,
flex: 'none',
borderRadius: 10,
background: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
color: 'var(--color-brand)',
}}
>
{problem.bucket === 'now' ? <AlertTriangle size={17} /> : <Monitor size={17} />}
</span>
<VStack gap={1} 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 }}>
<Button
label={showDetails ? 'Hide details' : 'Details'}
variant="secondary"
size="sm"
onClick={() => setShowDetails((open) => !open)}
/>
<Button
label="Rename"
variant="ghost"
size="sm"
icon={<Pencil size={12} />}
onClick={() => setIsRenaming(true)}
/>
{problem.code === 'never-used' || problem.code === 'no-status' ? (
<Button
label="Hide this counter"
variant="ghost"
size="sm"
icon={<EyeOff size={12} />}
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>
<Button
label="Save"
variant="secondary"
size="sm"
icon={<Check size={12} />}
onClick={() => {
labels.rename(card.terminalId, draft);
setIsRenaming(false);
}}
/>
<Button
label="Cancel"
variant="ghost"
size="sm"
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 ───────────────────────────────────────────────────────── */
/**
* Counters with nothing wrong.
*
* A Table, like every other list in the console — Stores, Store detail, Users,
* Sales. This used to be a stack of hand-built rows, each one a bordered card
* carrying a name and a run-on line of `84 sales · ₹52,100 · last sale 14:22`.
* Four of those read as four more problems; in a table the same four read as
* what they are, which is a quiet list of shops doing their job.
*
* Renaming moves into the row's own control rather than being inline, because
* a text field cannot live inside a table cell without breaking the column.
*/
interface FineRowData extends Record<string, unknown> {
key: string;
name: string;
code: string;
branch: string;
bills: number;
amount: number;
lastSale: string;
terminalId: string;
}
function FineTable({
counters,
labels,
}: {
counters: HealthyCounter[];
labels: CounterLabels;
}) {
const [renaming, setRenaming] = useState<HealthyCounter | null>(null);
const rows: FineRowData[] = counters.map((counter) => ({
key: counter.key,
name: labels.nameFor(counter.terminalId),
// Blank rather than repeated when the counter has not been renamed: the
// name column already IS the code then, and printing it twice on one row
// is the kind of noise that makes a table look machine-generated.
code: labels.isNamed(counter.terminalId) ? counter.terminalId : '',
branch: branchLabel(counter.branchName),
bills: counter.periodBills,
amount: counter.periodAmount,
lastSale: counter.status.lastBillAt ? clockTime(counter.status.lastBillAt) : '—',
terminalId: counter.terminalId,
}));
const paged = usePaged(rows);
const columns: TableColumn<FineRowData>[] = [
{
key: 'name',
header: 'Counter',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.name}
</Text>
{row.code ? (
<Text
type="body"
size="xsm"
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
>
{row.code}
</Text>
) : null}
</VStack>
),
},
{
key: 'branch',
header: 'Branch',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.branch}
</Text>
),
},
{
key: 'bills',
header: 'Sales',
align: 'end',
width: { type: 'pixel', value: 90 },
renderCell: (row) => (
<Text type="body" size="sm" hasTabularNumbers>
{row.bills}
</Text>
),
},
{
key: 'amount',
header: 'Taken',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{rupees(row.amount)}
</Text>
),
},
{
key: 'lastSale',
header: 'Last sale',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
{row.lastSale}
</Text>
),
},
{
key: 'rename',
header: '',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Button
label="Rename"
variant="ghost"
size="sm"
onClick={() => {
const found = counters.find((counter) => counter.key === row.key);
if (found) setRenaming(found);
}}
/>
),
},
];
return (
<VStack gap={1.5}>
{renaming ? (
<Card padding={0} variant="transparent">
<div style={{ ...panelStyle, padding: 14 }}>
<CounterName
card={renaming}
labels={labels}
isRenaming
setIsRenaming={() => setRenaming(null)}
/>
</div>
</Card>
) : null}
<Card padding={0} variant="transparent">
<div style={{ ...panelStyle, overflow: 'hidden' }}>
<Table<FineRowData>
data={paged.rows}
columns={columns}
idKey="key"
density="balanced"
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="terminals" />
</Card>
</VStack>
);
}
/* ── Hidden ──────────────────────────────────────────────────────────────── */
function HiddenList({ labels }: { labels: CounterLabels }) {
return (
<Card padding={0} variant="transparent">
<VStack gap={1} style={{ ...panelStyle, padding: 16 }}>
{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>
<Button
label="Bring back"
variant="ghost"
size="sm"
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} variant="transparent">
<VStack gap={0.5} style={{ ...panelStyle, padding: 20 }}>
<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>
);
}