lat and long

This commit is contained in:
2026-08-27 13:58:19 +05:30
parent 60efac7a7e
commit a214ea67da
15 changed files with 1816 additions and 445 deletions

View File

@@ -1,18 +1,31 @@
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, Pencil } from 'lucide-react';
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_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
/**
@@ -43,20 +56,22 @@ export function TerminalsPage() {
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 `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.`;
return 'Everything they’ve sold has reached us. Each counter 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 'Counters are listed by the code printed on your receipts, until you rename them.';
}, [board.isLoading, board.total, needs]);
return (
<VStack gap={3}>
@@ -77,11 +92,45 @@ export function TerminalsPage() {
}
/>
{/* 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} elevation="low">
<HStack gap={1.5} align="start" padding={2}>
<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>
@@ -122,9 +171,7 @@ export function TerminalsPage() {
isOpen={showFine}
onToggle={() => setShowFine((open) => !open)}
>
{board.fine.map((counter) => (
<FineRow key={counter.key} counter={counter} labels={labels} />
))}
<FineTable counters={board.fine} labels={labels} />
</Group>
) : null}
@@ -158,6 +205,20 @@ export function TerminalsPage() {
);
}
/**
* 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({
@@ -177,54 +238,29 @@ function Group({
}) {
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>
{/* `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>
);
@@ -243,13 +279,33 @@ 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 }}>
<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">
@@ -290,15 +346,25 @@ function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels
) : null}
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
<SmallButton
<Button
label={showDetails ? 'Hide details' : 'Details'}
variant="secondary"
size="sm"
onClick={() => setShowDetails((open) => !open)}
/>
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
<Button
label="Rename"
variant="ghost"
size="sm"
icon={<Pencil size={12} />}
onClick={() => setIsRenaming(true)}
/>
{problem.code === 'never-used' || problem.code === 'no-status' ? (
<SmallButton
<Button
label="Hide this counter"
icon={<EyeOff size={11} />}
variant="ghost"
size="sm"
icon={<EyeOff size={12} />}
onClick={() => labels.hide(card.terminalId)}
/>
) : null}
@@ -345,15 +411,22 @@ function CounterName({
placeholder="Counter 2, by the door…"
/>
</div>
<SmallButton
<Button
label="Save"
icon={<Check size={11} />}
variant="secondary"
size="sm"
icon={<Check size={12} />}
onClick={() => {
labels.rename(card.terminalId, draft);
setIsRenaming(false);
}}
/>
<SmallButton label="Cancel" onClick={() => 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>
@@ -454,31 +527,164 @@ function Details({ card }: { card: BoardCard }) {
/* ── 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)}` : ''}
/**
* 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 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>
) : null}
</HStack>
</Card>
{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={rows}
columns={columns}
idKey="key"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</Card>
</VStack>
);
}
@@ -486,14 +692,19 @@ function FineRow({ counter, labels }: { counter: HealthyCounter; labels: Counter
function HiddenList({ labels }: { labels: CounterLabels }) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<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>
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
<Button
label="Bring back"
variant="ghost"
size="sm"
onClick={() => labels.unhide(terminalId)}
/>
</HStack>
))}
<div style={{ height: 1, background: 'var(--color-line)' }} />
@@ -510,8 +721,8 @@ function HiddenList({ labels }: { labels: CounterLabels }) {
function Empty({ checked }: { checked: string[] }) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<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>
@@ -529,38 +740,3 @@ function Empty({ checked }: { checked: string[] }) {
);
}
/* ── 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>
);
}