748 lines
26 KiB
TypeScript
748 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|