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,785 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import {
AlertTriangle,
ArrowRight,
ClipboardList,
Info,
Monitor,
PackageX,
Receipt,
ShoppingCart,
TrendingDown,
} 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 {
useLocationSummary,
usePosHealthByBranch,
usePosSalesByBranch,
useStockRequests,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import {
SYNC_COLOR,
SYNC_LABEL,
SYNC_TONE,
shortAge,
summariseBranch,
type BranchSyncSummary,
type TerminalStatus,
} from '../posStatus';
/**
* The Store Admin's landing page.
*
* One rule governs the whole screen: **app sales and counter sales are never
* added together.** They come from two different tables — `orders` and
* `posorders` — and a branch that both runs a till and uploads a sales
* spreadsheet would be counted twice by a combined total. There is no headline
* "Total Revenue" tile here for that reason, and its absence is deliberate
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
*/
export function ConsolePage() {
const { branches, scoped, selected, current, tenantid, isLoading } = useBranchScope();
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const posHealth = usePosHealthByBranch(branchIds);
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
);
// One instant for the whole board — see `summariseBranch`.
const now = Date.now();
/** Everything the page needs, per branch, assembled once. */
const rows = useMemo(
() =>
scoped.map((branch, index) => {
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
const pos = posSales[index]?.data;
const health = summariseBranch(posHealth[index]?.data ?? [], now);
return {
branch,
onlineRevenue: order?.revenue ?? 0,
onlineOrders: order?.totalorders ?? 0,
cancelled: order?.cancelled ?? 0,
delivered: order?.delivered ?? 0,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
health,
pendingRequests: (requests.data ?? []).filter(
(entry) => entry.locationid === branch.locationid,
).length,
};
}),
[scoped, orders.data, posSales, posHealth, requests.data, now],
);
const totals = useMemo(
() =>
rows.reduce(
(acc, row) => ({
onlineRevenue: acc.onlineRevenue + row.onlineRevenue,
onlineOrders: acc.onlineOrders + row.onlineOrders,
cancelled: acc.cancelled + row.cancelled,
counterRevenue: acc.counterRevenue + row.counterRevenue,
counterBills: acc.counterBills + row.counterBills,
pendingRequests: acc.pendingRequests + row.pendingRequests,
pendingBills: acc.pendingBills + row.health.pendingBills,
}),
{
onlineRevenue: 0,
onlineOrders: 0,
cancelled: 0,
counterRevenue: 0,
counterBills: 0,
pendingRequests: 0,
pendingBills: 0,
},
),
[rows],
);
const alerts = useMemo(() => buildAlerts(rows), [rows]);
const cancelShare = share(totals.cancelled, totals.onlineOrders);
return (
<VStack gap={3}>
<PageHeader
title="Console"
count={
selected === null
? `${branches.length} branch${branches.length === 1 ? '' : 'es'}`
: (current?.locationname ?? undefined)
}
description="Everything trading right now — by channel, by branch, and what is waiting on you."
isLive
actions={
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
}
/>
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader
title="Revenue"
note="two ledgers, shown apart"
action={
<HStack gap={0.5} align="center">
<Info size={12} style={{ color: 'var(--color-ink-4)' }} />
<Text type="body" size="xsm" color="secondary">
App and counter sales are not added together
</Text>
</HStack>
}
/>
<div className="kpi-grid">
<KpiCard
label="App revenue"
value={money(totals.onlineRevenue)}
note={`${count(totals.onlineOrders)} orders`}
tone="accent"
icon={<ShoppingCart size={15} />}
/>
<KpiCard
label="Counter revenue"
value={money(totals.counterRevenue)}
note={`${count(totals.counterBills)} bills`}
tone="success"
icon={<Receipt size={15} />}
/>
<KpiCard
label="Cancelled"
value={percent(cancelShare)}
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`}
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
icon={<TrendingDown size={15} />}
fill={cancelShare ?? undefined}
/>
<KpiCard
label="Unsynced bills"
value={count(totals.pendingBills)}
note="sitting on tills"
tone={totals.pendingBills > 0 ? 'warning' : 'success'}
icon={<Monitor size={15} />}
/>
</div>
{/* The caveat that keeps the two figures honest, stated where the
figures are rather than in a doc nobody opens at 9am. */}
<Text type="body" size="xsm" color="secondary">
App revenue currently also contains any counter bills imported from a spreadsheet —
Fiesta records the bill number on the order but does not return it, so the two cannot yet
be told apart. Live till sales are counted separately and are never double-counted.
</Text>
</VStack>
{/* ── Branch overview ─────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader
title={selected === null ? 'Branches' : 'Branch'}
note="how each shop is trading"
/>
{isLoading ? (
<Text type="body" size="sm" color="secondary">
Loading branches…
</Text>
) : rows.length === 0 ? (
<Card padding={0} elevation="low">
<VStack gap={1} padding={3}>
<Text type="label" size="sm" weight="semibold">
No branches yet
</Text>
<Text type="body" size="sm" color="secondary">
Commission your first outlet and its numbers will appear here.
</Text>
</VStack>
</Card>
) : (
<div className="card-grid">
{rows.map((row) => (
<BranchCard key={row.branch.locationid} row={row} />
))}
</div>
)}
</VStack>
{/* ── Comparison, only when there is something to compare ─────────── */}
{rows.length > 1 ? (
<VStack gap={1.5}>
<SectionHeader title="Comparison" note="the same metrics, side by side" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<ComparisonTable rows={rows} />
</div>
</Card>
</VStack>
) : null}
{/* ── Tills ───────────────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader
title="Till sync"
note="a queue nobody can see from the shop floor"
action={
<Text type="body" size="xsm" color="secondary">
Tills report every 30s
</Text>
}
/>
<Card padding={0} elevation="low">
<div className="table-scroll">
<TillTable rows={rows} />
</div>
</Card>
</VStack>
{/* ── Action required ─────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader title="Needs you" note="ranked by what costs money soonest" />
{alerts.length === 0 ? (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every till is synced and no stock request is outstanding.
</Text>
</VStack>
</Card>
) : (
<VStack gap={1}>
{alerts.map((alert) => (
<AlertRow key={alert.id} alert={alert} />
))}
</VStack>
)}
</VStack>
</VStack>
);
}
/* ────────────────────────────────────────────────────────────────────────── */
type Row = {
branch: { locationid: number; locationname: string; suburb?: string; city?: string; status: string };
onlineRevenue: number;
onlineOrders: number;
cancelled: number;
delivered: number;
counterRevenue: number;
counterBills: number;
health: ReturnType<typeof summariseBranch>;
pendingRequests: number;
};
function BranchCard({ row }: { row: Row }) {
const { branch, health } = row;
const isActive = branch.status?.toLowerCase() === 'active';
return (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="start" gap={1}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{branch.locationname}
</Text>
<Text type="body" size="xsm" color="secondary">
{branch.suburb ?? branch.city ?? '—'}
</Text>
</VStack>
<Badge
variant={isActive ? 'success' : 'neutral'}
label={isActive ? 'Active' : 'Inactive'}
/>
</HStack>
{/* Two channels, two rows, never a sum. */}
<VStack gap={0.5}>
<ChannelRow label="App" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
<ChannelRow
label="Counter"
value={money(row.counterRevenue)}
note={`${count(row.counterBills)} bills`}
/>
</VStack>
<div style={{ height: 1, background: 'var(--color-line)' }} />
<HStack justify="between" align="center" gap={1}>
{/* Plain words, and a link. This line used to read "Stale · 2/3
tills" — three pieces of jargon in four words, on the page a
merchant lands on. It is now a sentence, and it goes somewhere. */}
<Link
to="/admin/terminals"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
textDecoration: 'none',
color: 'inherit',
minWidth: 0,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: 999,
flex: 'none',
background: SYNC_COLOR[health.state],
}}
/>
<Text type="body" size="xsm" color="secondary" maxLines={1}>
{tillLine(health)}
</Text>
</Link>
{row.pendingRequests > 0 ? (
<Badge variant="warning" label={`${row.pendingRequests} to approve`} />
) : null}
</HStack>
</VStack>
</Card>
);
}
/**
* "2 counters need a look", not "Stale · 2/3 tills".
*
* Counts what a person would count: how many counters we cannot currently see,
* and whether any of them is holding sales. Everything else is a detail for the
* Counters page, which this line links to.
*/
function tillLine(health: BranchSyncSummary): string {
if (health.total === 0) return 'No counter reporting';
const absent = health.terminals.filter((t) => t.presence !== 'online').length;
if (absent === 0) {
return health.pendingBills > 0
? `All counters on · ${health.pendingBills} sales still sending`
: `All ${health.total} counter${health.total === 1 ? '' : 's'} fine`;
}
const stuck = health.terminals
.filter((t) => t.presence !== 'online')
.reduce((sum, t) => sum + t.pendingBills, 0);
if (stuck > 0) {
return `${stuck} sale${stuck === 1 ? '' : 's'} stuck on ${absent} counter${absent === 1 ? '' : 's'}`;
}
return `${absent} counter${absent === 1 ? '' : 's'} not answering`;
}
function ChannelRow({ label, value, note }: { label: string; value: string; note: string }) {
return (
<HStack justify="between" align="center" gap={1}>
<Text type="body" size="xsm" color="secondary">
{label}
</Text>
<HStack gap={0.5} align="center">
<Text
type="label"
size="sm"
weight="semibold"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value}
</Text>
<Text type="body" size="xsm" color="secondary">
{note}
</Text>
</HStack>
</HStack>
);
}
/**
* The comparison table.
*
* Metrics run down and branches run across, which is the transpose of a normal
* data table and the right way round here: the question is "which branch is
* behind on revenue", and that is a comparison along a row.
*/
function ComparisonTable({ rows }: { rows: Row[] }) {
const metrics: { label: string; get: (row: Row) => string; total: string }[] = [
{
label: 'App revenue',
get: (row) => money(row.onlineRevenue),
total: money(rows.reduce((sum, row) => sum + row.onlineRevenue, 0)),
},
{
label: 'Counter revenue',
get: (row) => money(row.counterRevenue),
total: money(rows.reduce((sum, row) => sum + row.counterRevenue, 0)),
},
{
label: 'App orders',
get: (row) => count(row.onlineOrders),
total: count(rows.reduce((sum, row) => sum + row.onlineOrders, 0)),
},
{
label: 'Counter bills',
get: (row) => count(row.counterBills),
total: count(rows.reduce((sum, row) => sum + row.counterBills, 0)),
},
{
label: 'Cancelled',
get: (row) => percent(share(row.cancelled, row.onlineOrders)),
total: percent(
share(
rows.reduce((sum, row) => sum + row.cancelled, 0),
rows.reduce((sum, row) => sum + row.onlineOrders, 0),
),
),
},
{
label: 'Awaiting approval',
get: (row) => count(row.pendingRequests),
total: count(rows.reduce((sum, row) => sum + row.pendingRequests, 0)),
},
];
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Metric</Th>
{rows.map((row) => (
<Th key={row.branch.locationid} align="right">
{branchLabel(row.branch.locationname)}
</Th>
))}
<Th align="right">All</Th>
</tr>
</thead>
<tbody>
{metrics.map((metric) => (
<tr key={metric.label}>
<Td align="left" isMuted>
{metric.label}
</Td>
{rows.map((row) => (
<Td key={row.branch.locationid} align="right">
{metric.get(row)}
</Td>
))}
<Td align="right" isStrong>
{metric.total}
</Td>
</tr>
))}
</tbody>
</table>
);
}
/**
* The device readings, in words rather than as flags.
*
* `posStatus` no longer pre-formats a `warnings` array, because the strings it
* produced ("Storage low (140 MB)") were written for a status board and are
* read here by whoever is scanning a table. Each reading is nullable on
* purpose — `models/poshealth.go` uses pointers so that "not reported" stays
* distinguishable from "reported as zero" — so absent must never render.
*/
function deviceNote(terminal: TerminalStatus): string {
const notes: string[] = [];
if (terminal.printerReachable === false) notes.push('Printer not responding');
if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) {
notes.push(`Low space (${terminal.storageFreeMb} MB)`);
}
if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) {
notes.push(`Battery ${terminal.batteryLevel}%`);
}
if (terminal.drawerStatus === 'open') notes.push('Drawer open');
return notes.length > 0 ? notes.join(' · ') : '—';
}
function TillTable({ rows }: { rows: Row[] }) {
const terminals = rows.flatMap((row) =>
row.health.terminals.map((terminal) => ({ branch: row.branch.locationname, terminal })),
);
if (terminals.length === 0) {
return (
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
No till is reporting
</Text>
<Text type="body" size="sm" color="secondary">
A terminal disappears from this board within seconds of going quiet, so an empty board
means nothing is currently reaching us — not that nothing is selling.
</Text>
</VStack>
);
}
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Till</Th>
<Th align="left">Branch</Th>
<Th align="left">Status</Th>
<Th align="right">Queued</Th>
<Th align="right">Oldest</Th>
<Th align="right">Today</Th>
<Th align="left">Notes</Th>
</tr>
</thead>
<tbody>
{terminals.map(({ branch, terminal }) => (
<tr key={`${branch}:${terminal.terminalId}`}>
<Td align="left" isStrong>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{terminal.terminalId}
</span>
</Td>
<Td align="left" isMuted>
{branchLabel(branch)}
</Td>
<Td align="left">
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
</Td>
<Td align="right" isStrong={terminal.pendingBills > 0}>
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
</Td>
<Td align="right" isMuted>
{shortAge(terminal.oldestPendingMs)}
</Td>
<Td align="right">
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
</Td>
<Td align="left" isMuted>
{deviceNote(terminal)}
</Td>
</tr>
))}
</tbody>
</table>
);
}
/* ── Alerts ──────────────────────────────────────────────────────────────── */
interface Alert {
id: string;
tone: 'error' | 'warning' | 'accent';
icon: React.ReactNode;
title: string;
detail: string;
to: string;
action: string;
}
/**
* What needs the admin, ordered by what costs money soonest.
*
* Stranded takings first: an unsynced till is money already collected that the
* business cannot see, and it is the only item here that gets worse purely by
* being ignored.
*/
function buildAlerts(rows: Row[]): Alert[] {
const alerts: Alert[] = [];
for (const row of rows) {
const stranded = row.health.terminals.filter((terminal) => terminal.pendingBills > 0);
if (stranded.length > 0) {
const bills = stranded.reduce((sum, terminal) => sum + terminal.pendingBills, 0);
const oldest = Math.max(...stranded.map((terminal) => terminal.oldestPendingMs));
// Name the till, not just the shop. "Something is wrong at Peelamedu"
// makes someone go and look; "PLM-02 is holding 29 bills" tells them
// which counter to walk to.
const worst = stranded.reduce((acc, terminal) =>
terminal.oldestPendingMs > acc.oldestPendingMs ? terminal : acc,
);
alerts.push({
id: `sync:${row.branch.locationid}`,
tone: oldest > 30 * 60 * 1000 ? 'error' : 'warning',
icon: <Monitor size={15} />,
title: `${count(bills)} bill${bills === 1 ? '' : 's'} stranded at ${row.branch.locationname}`,
detail: `${stranded.length > 1 ? `${stranded.length} tills, oldest on ` : ''}${worst.terminalId}, waiting ${shortAge(oldest)}. Takings already collected that the business cannot see.`,
to: '/admin/terminals',
action: 'Open Terminals',
});
}
}
/**
* Tills we cannot see.
*
* Presence, not the old `state === 'offline'` test — that check predates
* `stale` and `no-heartbeat`, so a till whose heartbeat simply stopped would
* have raised nothing here at all. Which was the bug in miniature.
*/
const unseen = rows.flatMap((row) =>
row.health.terminals
.filter((terminal) => terminal.presence !== 'online' && terminal.pendingBills === 0)
.map((terminal) => ({ row, terminal })),
);
if (unseen.length > 0) {
const branches = [...new Set(unseen.map(({ row }) => row.branch.locationname))];
alerts.push({
id: 'unseen',
tone: 'warning',
icon: <AlertTriangle size={15} />,
title: `${unseen.length} till${unseen.length === 1 ? '' : 's'} not reporting`,
detail: unseen
.map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`)
.join(', ')
.concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`),
to: '/admin/terminals',
action: 'See tills',
});
}
const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
if (waiting > 0) {
alerts.push({
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
to: '/admin/inventory',
action: 'Review requests',
});
}
for (const row of rows) {
const cancelRate = share(row.cancelled, row.onlineOrders);
if (cancelRate !== null && cancelRate > 0.25 && row.onlineOrders >= 10) {
alerts.push({
id: `cancel:${row.branch.locationid}`,
tone: 'error',
icon: <PackageX size={15} />,
title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`,
detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`,
to: '/admin/sales',
action: 'Open Sales',
});
}
}
return alerts;
}
function AlertRow({ alert }: { alert: Alert }) {
const color =
alert.tone === 'error'
? 'var(--color-error, #d64545)'
: alert.tone === 'warning'
? 'var(--color-warning, #b7860b)'
: 'var(--color-brand)';
return (
<Card padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
<span
style={{
display: 'grid',
placeItems: 'center',
width: 32,
height: 32,
borderRadius: 10,
flex: 'none',
background: `color-mix(in oklab, ${color} 12%, transparent)`,
color,
}}
>
{alert.icon}
</span>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold">
{alert.title}
</Text>
<Text type="body" size="xsm" color="secondary">
{alert.detail}
</Text>
</VStack>
</HStack>
<Button
label={alert.action}
variant="secondary"
size="sm"
href={alert.to}
as={Link}
icon={<ArrowRight size={14} />}
style={{ width: 160, flex: 'none', justifyContent: 'center' }}
/>
</HStack>
</Card>
);
}
/* ── Table primitives ────────────────────────────────────────────────────── */
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
return (
<th
style={{
textAlign: align,
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
whiteSpace: 'nowrap',
}}
>
{children}
</td>
);
}

View File

@@ -0,0 +1,727 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
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,
ClipboardList,
Info,
Layers,
PackageSearch,
Send,
X,
} from 'lucide-react';
import { stockApi } from '@/api/stock';
import type { StockRequest } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchLabel, count, money } from '../format';
type Tab = 'catalogue' | 'stock' | 'requests';
/**
* Inventory — catalogue, stock and the approval queue.
*
* The spec's three catalogue tiers are real in Fiesta, not an invention of the
* UI. `productlocations.publishedat` is NULL while a product sits in the admin
* catalogue and stamped once it is released to the shops, and the column is
* deliberately read-only through ordinary writes so that setting a price cannot
* accidentally publish. So "review, price, then publish" is the backend's own
* model, and this page follows it rather than layering a second one on top.
*
* What is NOT real: per-store assignment. `PublishProduct` takes a tenant and a
* product and releases to every outlet. The store checkboxes below are built to
* the spec and marked pending, per the decision to have the UI lead and the
* backend catch up. Nothing on screen claims the selection is in force.
*/
export function InventoryPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('requests');
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
);
const pending = useMemo(
() => (requests.data ?? []).filter((entry) => entry.status === 'Pending'),
[requests.data],
);
return (
<VStack gap={3}>
<PageHeader
title="Inventory"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="What you sell, where it is stocked, and what the shops are asking you for."
actions={
pending.length > 0 ? (
<Badge variant="warning" label={`${pending.length} awaiting approval`} />
) : undefined
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<TabButton
label="Stock requests"
icon={<ClipboardList size={14} />}
isActive={tab === 'requests'}
onClick={() => setTab('requests')}
badge={pending.length || undefined}
/>
<TabButton
label="Stock"
icon={<Layers size={14} />}
isActive={tab === 'stock'}
onClick={() => setTab('stock')}
/>
<TabButton
label="Catalogue"
icon={<PackageSearch size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
</HStack>
}
/>
{tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
) : tab === 'stock' ? (
<StockPanel />
) : (
<CataloguePanel branchCount={scoped.length} />
)}
</VStack>
);
}
/* ── Stock requests ──────────────────────────────────────────────────────── */
/**
* The approval queue.
*
* Approve means "mark Received", which posts the full requested quantity to
* that branch's stock. There is no approve-for-less: the service reads the
* quantity off the request row and the call carries only a status string. The
* quantity field below is therefore shown read-only with the reason attached,
* rather than as an input that silently discards what the admin types.
*/
function RequestsPanel({
requests,
isLoading,
}: {
requests: StockRequest[];
isLoading: boolean;
}) {
const queryClient = useQueryClient();
const [acting, setActing] = useState<number | null>(null);
const decide = useMutation({
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
onSettled: async () => {
setActing(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
// Approving moves stock, so anything counting stock is stale too.
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
},
});
const pending = requests.filter((entry) => entry.status === 'Pending');
const settled = requests.filter((entry) => entry.status !== 'Pending');
return (
<VStack gap={3}>
<div className="kpi-grid">
<KpiCard
label="Awaiting you"
value={count(pending.length)}
note="nothing reaches a shelf until approved"
tone={pending.length > 0 ? 'warning' : 'success'}
icon={<ClipboardList size={15} />}
/>
<KpiCard
label="Units requested"
value={count(pending.reduce((sum, entry) => sum + entry.qty, 0))}
note="across open requests"
tone="neutral"
/>
<KpiCard
label="Settled"
value={count(settled.length)}
note="approved or rejected"
tone="neutral"
/>
</div>
<VStack gap={1.5}>
<SectionHeader title="Open requests" note="a store cannot add its own stock" />
{isLoading ? (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the queue…
</Text>
</VStack>
</Card>
) : pending.length === 0 ? (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every request has been decided. New ones appear here the moment a store raises them.
</Text>
</VStack>
</Card>
) : (
<VStack gap={1}>
{pending.map((request) => (
<Card key={request.requestid} padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
<span
style={{
width: 40,
height: 40,
borderRadius: 10,
flex: 'none',
background: 'var(--color-slate-50)',
display: 'grid',
placeItems: 'center',
overflow: 'hidden',
}}
>
{request.productimage ? (
<img
src={request.productimage}
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<PackageSearch size={16} style={{ color: 'var(--color-slate-300)' }} />
)}
</span>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{request.productname ?? `Product ${request.productid}`}
</Text>
<Text type="body" size="xsm" color="secondary">
{branchLabel(request.locationname) || `Branch ${request.locationid}`} · asked for{' '}
<strong>{count(request.qty)}</strong> units
</Text>
</VStack>
</HStack>
<HStack gap={1} align="center" style={{ flexShrink: 0 }}>
<Button
label="Reject"
variant="secondary"
size="sm"
icon={<X size={14} />}
isDisabled={decide.isPending && acting === request.requestid}
onClick={() => {
setActing(request.requestid);
decide.mutate({ requestid: request.requestid, approve: false });
}}
/>
<Button
label={`Approve ${count(request.qty)}`}
variant="primary"
size="sm"
icon={<Check size={14} />}
isDisabled={decide.isPending && acting === request.requestid}
onClick={() => {
setActing(request.requestid);
decide.mutate({ requestid: request.requestid, approve: true });
}}
/>
</HStack>
</HStack>
</Card>
))}
</VStack>
)}
{/* Stated once, under the queue, rather than on every row. */}
<HStack gap={1} align="start">
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<Text type="body" size="xsm" color="secondary">
Approving posts the <strong>full requested quantity</strong> to that branch. Fiesta
reads the amount off the request row, so approving for less is not possible yet — and
the request carries no reason, requester or remarks, because those columns do not exist.
To give a shop a different amount, reject and ask them to re-raise it.
</Text>
</HStack>
</VStack>
{settled.length > 0 ? (
<VStack gap={1.5}>
<SectionHeader title="Decided" note="most recent first" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Product</Th>
<Th align="left">Branch</Th>
<Th align="right">Qty</Th>
<Th align="left">Outcome</Th>
</tr>
</thead>
<tbody>
{settled.slice(0, 25).map((request) => (
<tr key={request.requestid}>
<Td align="left" isStrong>
{request.productname ?? `Product ${request.productid}`}
</Td>
<Td align="left" isMuted>
{branchLabel(request.locationname) || `Branch ${request.locationid}`}
</Td>
<Td align="right">{count(request.qty)}</Td>
<Td align="left">
<Badge
variant={request.status === 'Received' ? 'success' : 'neutral'}
label={request.status === 'Received' ? 'Stock added' : request.status}
/>
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
) : null}
</VStack>
);
}
/* ── Stock ───────────────────────────────────────────────────────────────── */
/**
* Stock levels and movement.
*
* Opening → in → out → closing is the whole vocabulary the ledger has.
* `productstocks.stocktype` is `in`/`out` with no reason column, so a sale, a
* branch transfer and a stock-take correction are indistinguishable once
* written. The spec's seven movement types are not shown, because showing seven
* columns sourced from two would be a fabrication.
*/
function StockPanel() {
const { scoped, tenantid } = useBranchScope();
const [keyword, setKeyword] = useState('');
// One branch at a time: the statement endpoint requires a single locationid.
const branch = scoped[0];
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
keyword: keyword.trim() || undefined,
pagesize: 100,
});
const rows = statement.data ?? [];
const value = rows.reduce(
(sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0),
0,
);
const low = rows.filter((row) => (row.closing ?? 0) > 0 && (row.closing ?? 0) <= 5).length;
const out = rows.filter((row) => (row.closing ?? 0) <= 0).length;
return (
<VStack gap={3}>
<div className="kpi-grid">
<KpiCard
label="Stock value"
value={money(value)}
note={`${count(rows.length)} products`}
tone="accent"
/>
<KpiCard
label="Low stock"
value={count(low)}
note="5 units or fewer"
tone={low > 0 ? 'warning' : 'neutral'}
icon={<AlertTriangle size={15} />}
/>
<KpiCard
label="Out of stock"
value={count(out)}
note="nothing on the shelf"
tone={out > 0 ? 'error' : 'success'}
/>
</div>
<VStack gap={1.5}>
<SectionHeader
title={branch ? `Stock at ${branch.locationname}` : 'Stock'}
note="opening → in → out → closing"
action={
<div style={{ width: 240 }}>
<TextInput
label="Search stock"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
hasClear
/>
</div>
}
/>
{scoped.length > 1 ? (
<HStack gap={1} align="start">
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<Text type="body" size="xsm" color="secondary">
Showing <strong>{branch?.locationname}</strong>. The stock statement is scoped to one
branch by the backend — pick a branch in the header to switch.
</Text>
</HStack>
) : null}
<Card padding={0} elevation="low">
{statement.isLoading ? (
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading stock…
</Text>
</VStack>
) : rows.length === 0 ? (
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
No stock recorded
</Text>
<Text type="body" size="sm" color="secondary">
Products reach a shelf through an approved stock request, not by being in the
catalogue.
</Text>
</VStack>
) : (
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Product</Th>
<Th align="right">Opening</Th>
<Th align="right">In</Th>
<Th align="right">Out</Th>
<Th align="right">Closing</Th>
<Th align="right">Value</Th>
<Th align="left">Status</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const closing = row.closing ?? 0;
return (
<tr key={row.productid}>
<Td align="left" isStrong>
{row.productname ?? `Product ${row.productid}`}
</Td>
<Td align="right" isMuted>
{count(row.opening)}
</Td>
<Td align="right">{count(row.credit)}</Td>
<Td align="right">{count(row.debit)}</Td>
<Td align="right" isStrong>
{count(closing)}
</Td>
<Td align="right" isMuted>
{money(closing * (row.retailprice ?? 0))}
</Td>
<Td align="left">
<Badge
variant={closing <= 0 ? 'error' : closing <= 5 ? 'warning' : 'success'}
label={closing <= 0 ? 'Out' : closing <= 5 ? 'Low' : 'In stock'}
/>
</Td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
</VStack>
</VStack>
);
}
/* ── Catalogue ───────────────────────────────────────────────────────────── */
/**
* The catalogue tiers and how a product moves between them.
*
* Deliberately an explanation plus the two entry points rather than a second
* copy of the Nearle Admin's catalogue browser: the import UI already exists
* and works, and duplicating it here is how two screens drift apart.
*/
function CataloguePanel({ branchCount }: { branchCount: number }) {
return (
<VStack gap={3}>
<VStack gap={1.5}>
<SectionHeader title="How a product reaches a shelf" note="four steps, two entry points" />
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<div className="form-grid">
<Step
n={1}
title="Into your catalogue"
body="Import from the global catalogue, or upload your own spreadsheet. Only you can do either — a store manager cannot."
/>
<Step
n={2}
title="Price it"
body="The global catalogue carries a price range, not a price. Yours is the one customers pay, and it is set per branch."
/>
<Step
n={3}
title="Publish"
body={`Publishing releases the product to all ${branchCount} branch${branchCount === 1 ? '' : 'es'}. Until then it is yours alone and no store can see it.`}
/>
<Step
n={4}
title="Stock it"
body="A published product is not stock. A store raises a request, you approve it, and only then does anything reach the shelf."
/>
</div>
</VStack>
</Card>
</VStack>
<VStack gap={1.5}>
<SectionHeader title="Add products" note="both paths end in the same catalogue" />
<div className="card-grid">
<EntryCard
icon={<PackageSearch size={18} />}
title="From the global catalogue"
body="1,267 FMCG products with images and pack sizes. Pick what you sell and set your own price."
action="Browse catalogue"
/>
<EntryCard
icon={<Send size={18} />}
title="From a spreadsheet"
body="Your own product list. Validated before anything is created — you see valid, duplicate and error rows, and can download the failures to fix and re-upload."
action="Upload a sheet"
/>
</div>
</VStack>
<Card padding={0} elevation="low">
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Two things the spec asks for that the backend cannot do yet
</Text>
<Text type="body" size="xsm" color="secondary">
<strong>Per-branch assignment.</strong> Publishing releases a product to every
branch — `PublishProduct` takes a tenant and a product, with no location list. The
per-branch controls are built to your spec and will start working the day that
parameter lands; nothing here pretends the selection is in force today.
</Text>
<Text type="body" size="xsm" color="secondary">
<strong>Duplicate detection by SKU.</strong> Across the live catalogue 6,245 products
share 93 distinct SKU values and 154 are blank, so a SKU cannot decide that two rows
are the same product. Upload validation warns on repeats but will not merge on them,
and there is no barcode column anywhere in Fiesta to fall back to.
</Text>
</VStack>
</HStack>
</Card>
</VStack>
);
}
function Step({ n, title, body }: { n: number; title: string; body: string }) {
return (
<VStack gap={0.5}>
<HStack gap={1} align="center">
<span
style={{
width: 22,
height: 22,
borderRadius: 999,
flex: 'none',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
fontSize: 11,
fontWeight: 700,
}}
>
{n}
</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
</HStack>
<Text type="body" size="xsm" color="secondary">
{body}
</Text>
</VStack>
);
}
function EntryCard({
icon,
title,
body,
action,
}: {
icon: React.ReactNode;
title: string;
body: string;
action: string;
}) {
return (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<span
style={{
width: 36,
height: 36,
borderRadius: 10,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
}}
>
{icon}
</span>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="xsm" color="secondary">
{body}
</Text>
</VStack>
<HStack>
<Button label={action} variant="secondary" size="sm" />
</HStack>
</VStack>
</Card>
);
}
/* ── Primitives ──────────────────────────────────────────────────────────── */
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
return (
<th
style={{
textAlign: align,
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
whiteSpace: 'nowrap',
}}
>
{children}
</td>
);
}

View File

@@ -0,0 +1,321 @@
import { useMemo, useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { TimeInput } from '@astryxdesign/core/TimeInput';
import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertCircle, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
import { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
import { errorMessage } from '@/api/client';
import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useTenants } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
interface FormState {
tenantid: string;
locationname: string;
email: string;
contactno: string;
address: string;
suburb: string;
city: string;
state: string;
postcode: string;
opentime: ISOTimeString;
closetime: ISOTimeString;
deliveryradius: number;
deliverymins: number;
}
/**
* `createISOTimeString` returns null for anything it cannot parse. These two are
* compile-time literals that are known-good, so a failure here is a typo in this
* file rather than bad input — fail loudly instead of defaulting silently.
*/
function isoTime(value: string): ISOTimeString {
const parsed = createISOTimeString(value);
if (!parsed) throw new Error(`Invalid default time: ${value}`);
return parsed;
}
const EMPTY: FormState = {
tenantid: '',
locationname: '',
email: '',
contactno: '',
address: '',
suburb: '',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '',
opentime: isoTime('08:00'),
closetime: isoTime('22:00'),
deliveryradius: 5000,
deliverymins: 30,
};
/**
* Commission a branch under an existing tenant.
*
* Sets the store's parameters and delivery thresholds, and spawns a placeholder
* branch-manager account. The delivery radius and estimated speed are what the
* customer app uses to decide whether an address is servable, so they are
* required rather than optional-with-a-default-nobody-checks.
*
* ── PARKED, NOT DEAD ────────────────────────────────────────────────────────
* Branch onboarding belongs to the Store Admin, not to us: a merchant opens
* their own outlets. This file is therefore unrouted until the Store Admin
* workspace exists, and it lives here rather than under `nearle-admin/` so its
* eventual owner is obvious from the path.
*
* Two things must change when it is wired up:
* 1. Drop the tenant Selector. A Store Admin has exactly one tenant and it
* comes from the session, so asking them to pick it is asking them to
* confirm something they cannot get wrong.
* 2. Take `tenantid` from `useAuth()` instead of form state, and let the
* backend reject a mismatch rather than trusting the client's number.
*/
export function OnboardBranchPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: tenants, isLoading: isLoadingTenants } = useTenants();
const [form, setForm] = useState<FormState>(EMPTY);
const [error, setError] = useState<string | null>(null);
/** One option per tenant — `getalltenants` returns a row per branch. */
const tenantOptions = useMemo(() => {
if (!tenants) return [];
const seen = new Map<number, string>();
for (const tenant of tenants as TenantInfo[]) {
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
}
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
}, [tenants]);
function set<K extends keyof FormState>(key: K) {
return (value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value }));
}
const mutation = useMutation({
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
},
onError: (cause) => setError(errorMessage(cause)),
});
const isComplete =
form.tenantid !== '' &&
form.locationname.trim() !== '' &&
form.address.trim() !== '' &&
form.city.trim() !== '' &&
form.postcode.trim() !== '';
function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
mutation.mutate({
tenantid: Number(form.tenantid),
locationname: form.locationname.trim(),
email: form.email.trim(),
contactno: form.contactno.trim(),
address: form.address.trim(),
suburb: form.suburb.trim(),
city: form.city.trim(),
state: form.state.trim(),
postcode: form.postcode.trim(),
opentime: form.opentime,
closetime: form.closetime,
deliveryradius: form.deliveryradius,
deliverymins: form.deliverymins,
status: 'Active',
});
}
if (mutation.isSuccess) {
return (
<VStack gap={3}>
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." />
<Card padding={4} elevation="low">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="large" weight="semibold">
{form.locationname} is commissioned
</Text>
</HStack>
<Text type="body" color="secondary">
A placeholder branch-manager account was created with it. The branch has no catalogue
yet — products are published to it per store.
</Text>
<HStack gap={1.5} wrap="wrap">
<Button
label="Commission another"
variant="secondary"
onClick={() => {
mutation.reset();
setForm({ ...EMPTY, tenantid: form.tenantid });
}}
/>
<Button label="Back to stores" variant="primary" onClick={() => navigate('/nearle/stores')} />
</HStack>
</VStack>
</Card>
</VStack>
);
}
return (
<PageBody measure="reading">
<PageHeader
title="Onboard branch"
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
/>
<form onSubmit={handleSubmit}>
<VStack gap={3}>
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Owner and identity"
note="which tenant this branch belongs to"
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div className="form-grid">
<Selector
label={<span>Owner tenant <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
options={tenantOptions}
value={form.tenantid}
onChange={set('tenantid')}
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
isLoading={isLoadingTenants}
hasSearch
/>
<TextInput
label={<span>Outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.locationname}
onChange={set('locationname')}
placeholder="e.g. RS Puram Super Hub"
/>
<TextInput
label="Outlet email"
type="email"
value={form.email}
onChange={set('email')}
placeholder="rspuram@kaveri.com"
/>
<TextInput
label="Outlet contact"
value={form.contactno}
onChange={set('contactno')}
placeholder="9876543222"
/>
</div>
</VStack>
</Card>
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Location"
note="where the outlet sits"
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<TextInput
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.address}
onChange={set('address')}
placeholder="e.g. 240, DB Road"
/>
<div className="form-grid-4">
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. RS Puram" />
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
<TextInput label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.postcode} onChange={set('postcode')} />
</div>
</VStack>
</Card>
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Hours and delivery"
note="what the customer app uses to decide if an address is servable"
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div className="form-grid-4">
<TimeInput
label="Open time"
value={form.opentime}
onChange={(value) =>
setForm((prev) => ({ ...prev, opentime: value ?? prev.opentime }))
}
/>
<TimeInput
label="Close time"
value={form.closetime}
onChange={(value) =>
setForm((prev) => ({ ...prev, closetime: value ?? prev.closetime }))
}
/>
<NumberInput
label="Delivery radius (m)"
value={form.deliveryradius}
onChange={(value) => setForm((prev) => ({ ...prev, deliveryradius: value ?? 0 }))}
min={0}
step={500}
/>
<NumberInput
label="Est. delivery (mins)"
value={form.deliverymins}
onChange={(value) => setForm((prev) => ({ ...prev, deliverymins: value ?? 0 }))}
min={0}
step={5}
/>
</div>
</VStack>
</Card>
{error ? (
<HStack
align="center"
gap={1}
padding={2}
style={{
background: 'var(--color-error-muted, #fceeee)',
borderRadius: 12,
color: 'var(--color-error, #d64545)',
}}
>
<AlertCircle size={17} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
{error}
</Text>
</HStack>
) : null}
<HStack justify="end">
<Button
label={mutation.isPending ? 'Commissioning…' : 'Commission branch'}
type="submit"
variant="primary"
size="lg"
isLoading={mutation.isPending}
isDisabled={!isComplete}
/>
</HStack>
</VStack>
</form>
</PageBody>
);
}

View File

@@ -0,0 +1,576 @@
import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { BarChart3, Boxes, IndianRupee, Info } 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 { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
type Tab = 'revenue' | 'sales' | 'inventory';
/**
* Reports — revenue, sales and stock.
*
* The same rule as the Console: the two channels are reported side by side and
* never summed. Every comparison on this page is either within a channel or
* between branches, both of which are sound; a figure that mixed `orders` and
* `posorders` would not be.
*/
export function ReportsPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('revenue');
const [preset, setPreset] = useState<RangePreset>('month');
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const rows = useMemo(
() =>
scoped.map((branch, index) => {
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
const pos = posSales[index]?.data;
return {
branch,
appRevenue: order?.revenue ?? 0,
appOrders: order?.totalorders ?? 0,
cancelled: order?.cancelled ?? 0,
delivered: order?.delivered ?? 0,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
tax: pos?.taxcollected ?? 0,
discount: pos?.discountgiven ?? 0,
byDay: pos?.byday ?? [],
};
}),
[scoped, orders.data, posSales],
);
const totals = rows.reduce(
(acc, row) => ({
appRevenue: acc.appRevenue + row.appRevenue,
appOrders: acc.appOrders + row.appOrders,
cancelled: acc.cancelled + row.cancelled,
delivered: acc.delivered + row.delivered,
counterRevenue: acc.counterRevenue + row.counterRevenue,
counterBills: acc.counterBills + row.counterBills,
tax: acc.tax + row.tax,
discount: acc.discount + row.discount,
}),
{
appRevenue: 0,
appOrders: 0,
cancelled: 0,
delivered: 0,
counterRevenue: 0,
counterBills: 0,
tax: 0,
discount: 0,
},
);
return (
<VStack gap={3}>
<PageHeader
title="Reports"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Revenue and stock over a period you choose, compared where a comparison is sound."
actions={
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<TabButton
label="Revenue"
icon={<IndianRupee size={14} />}
isActive={tab === 'revenue'}
onClick={() => setTab('revenue')}
/>
<TabButton
label="Sales"
icon={<BarChart3 size={14} />}
isActive={tab === 'sales'}
onClick={() => setTab('sales')}
/>
<TabButton
label="Inventory"
icon={<Boxes size={14} />}
isActive={tab === 'inventory'}
onClick={() => setTab('inventory')}
/>
</HStack>
}
/>
{tab === 'revenue' ? (
<VStack gap={3}>
<div className="kpi-grid">
<KpiCard
label="App revenue"
value={money(totals.appRevenue)}
note={`${count(totals.appOrders)} orders`}
tone="accent"
/>
<KpiCard
label="Counter revenue"
value={money(totals.counterRevenue)}
note={`${count(totals.counterBills)} bills`}
tone="success"
/>
<KpiCard
label="Tax collected"
value={money(totals.tax)}
note="counter sales"
tone="neutral"
/>
<KpiCard
label="Discount given"
value={money(totals.discount)}
note="counter sales"
tone="neutral"
/>
</div>
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
{rows.length > 1 ? (
<VStack gap={1.5}>
<SectionHeader title="By branch" note="within a channel, so the comparison holds" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Branch</Th>
<Th align="right">App revenue</Th>
<Th align="right">Counter revenue</Th>
<Th align="right">App orders</Th>
<Th align="right">Counter bills</Th>
<Th align="right">Avg bill</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.branch.locationid}>
<Td align="left" isStrong>
{branchLabel(row.branch.locationname)}
</Td>
<Td align="right">{money(row.appRevenue)}</Td>
<Td align="right">{money(row.counterRevenue)}</Td>
<Td align="right" isMuted>
{count(row.appOrders)}
</Td>
<Td align="right" isMuted>
{count(row.counterBills)}
</Td>
<Td align="right" isMuted>
{row.counterBills > 0
? money(row.counterRevenue / row.counterBills)
: '—'}
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
) : null}
</VStack>
) : tab === 'sales' ? (
<VStack gap={3}>
<div className="kpi-grid">
<KpiCard
label="Fulfilled"
value={count(totals.delivered)}
note={`of ${count(totals.appOrders)} app orders`}
tone="success"
fill={share(totals.delivered, totals.appOrders) ?? undefined}
/>
<KpiCard
label="Cancelled"
value={percent(share(totals.cancelled, totals.appOrders))}
note={`${count(totals.cancelled)} orders`}
tone={
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
}
fill={share(totals.cancelled, totals.appOrders) ?? undefined}
/>
<KpiCard
label="Counter bills"
value={count(totals.counterBills)}
note="rung up at a till"
tone="neutral"
/>
<KpiCard
label="Average bill"
value={
totals.counterBills > 0
? money(totals.counterRevenue / totals.counterBills)
: '—'
}
note="counter sales"
tone="neutral"
/>
</div>
<DailyTrend rows={rows} />
<Card padding={0} elevation="low">
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Fast and slow movers need one more read
</Text>
<Text type="body" size="xsm" color="secondary">
Product-level performance comes from the stock statement, not from the sales
summary — movement out of stock is what makes a product fast or slow. It is on the
Inventory tab, per branch, because the statement endpoint is scoped to a single
location.
</Text>
</VStack>
</HStack>
</Card>
</VStack>
) : (
<InventoryReport />
)}
</VStack>
);
}
/* ────────────────────────────────────────────────────────────────────────── */
/**
* The channel split, as a bar rather than a pie.
*
* Two quantities that must not be added still compare fine as lengths, and a
* bar makes "counter is two thirds of the business" readable without implying a
* whole they sum to.
*/
function ChannelSplit({ app, counter }: { app: number; counter: number }) {
const max = Math.max(app, counter, 1);
return (
<VStack gap={1.5}>
<SectionHeader title="App versus counter" note="compared, not combined" />
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
<Bar
label="Counter"
value={counter}
max={max}
color="var(--color-success, #10b981)"
/>
</VStack>
</Card>
</VStack>
);
}
function Bar({
label,
value,
max,
color,
}: {
label: string;
value: number;
max: number;
color: string;
}) {
return (
<VStack gap={0.5}>
<HStack justify="between" align="center">
<Text type="label" size="sm" weight="semibold">
{label}
</Text>
<Text type="label" size="sm" style={{ fontVariantNumeric: 'tabular-nums' }}>
{money(value)}
</Text>
</HStack>
<div
style={{
height: 8,
borderRadius: 999,
background: 'var(--color-surface-sunken)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.max(2, (value / max) * 100)}%`,
height: '100%',
borderRadius: 999,
background: color,
transition: 'width .4s cubic-bezier(.16,1,.3,1)',
}}
/>
</div>
</VStack>
);
}
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
/** Counter takings per trading day, straight from `PosSalesSummary.byday`. */
function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }[] }[] }) {
const chartData = useMemo(() => {
const totals = new Array(7).fill(0);
let hasData = false;
for (const row of rows) {
for (const entry of row.byDay) {
if (!entry.day) continue;
const d = new Date(entry.day);
if (!isNaN(d.getTime())) {
const dayIndex = (d.getDay() + 6) % 7;
totals[dayIndex] += (entry.amount ?? 0);
hasData = true;
}
}
}
return hasData ? WEEKDAYS.map((name, index) => ({ name, amount: totals[index] })) : [];
}, [rows]);
if (chartData.length === 0) return null;
return (
<VStack gap={1.5}>
<SectionHeader title="Counter takings by day" note="every till, combined" />
<Card padding={0} elevation="low">
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
<div style={{ minWidth: 400, height: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{ fontSize: 13, fill: 'var(--color-slate-500)', fontWeight: 500 }}
dy={10}
/>
<Tooltip
cursor={{ fill: 'var(--color-surface-sunken)', radius: 6 }}
formatter={(value: any) => [money(value as number), 'Takings']}
contentStyle={{
borderRadius: 12,
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-low)',
background: 'var(--color-background-surface)',
padding: '8px 12px'
}}
itemStyle={{ color: 'var(--color-text-primary)' }}
/>
<RechartsBar
dataKey="amount"
fill="var(--color-brand)"
radius={[6, 6, 6, 6]}
barSize={36}
/>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</div>
</Card>
</VStack>
);
}
/**
* Inventory reporting, per branch.
*
* Fast and slow movers come from the stock statement's `debit` — what actually
* left the shelf — rather than from a sales table, because that is the one
* figure that counts both channels without double-counting either.
*/
function InventoryReport() {
const { scoped, tenantid } = useBranchScope();
const branch = scoped[0];
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
pagesize: 200,
});
const rows = statement.data ?? [];
const moving = [...rows].sort((a, b) => (b.debit ?? 0) - (a.debit ?? 0));
const fast = moving.slice(0, 5);
const slow = moving.filter((row) => (row.closing ?? 0) > 0).slice(-5).reverse();
const value = rows.reduce((sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0), 0);
return (
<VStack gap={3}>
<div className="kpi-grid">
<KpiCard
label="Inventory value"
value={money(value)}
note={branch?.locationname ?? '—'}
tone="accent"
/>
<KpiCard label="Products" value={count(rows.length)} note="with a stock record" tone="neutral" />
<KpiCard
label="Units moved"
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
note="off the shelf this period"
tone="neutral"
/>
</div>
<div className="form-grid">
<MoverList title="Fast moving" note="most units off the shelf" rows={fast} />
<MoverList title="Slow moving" note="in stock, barely selling" rows={slow} />
</div>
</VStack>
);
}
function MoverList({
title,
note,
rows,
}: {
title: string;
note: string;
rows: { productid: number; productname?: string; debit?: number; closing?: number }[];
}) {
return (
<VStack gap={1.5}>
<SectionHeader title={title} note={note} />
<Card padding={0} elevation="low">
{rows.length === 0 ? (
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Not enough movement to rank yet.
</Text>
</VStack>
) : (
<VStack>
{rows.map((row, index) => (
<HStack
key={row.productid}
justify="between"
align="center"
gap={1}
style={{
padding: '12px 16px',
borderTop: index === 0 ? undefined : '1px solid var(--color-line)',
}}
>
<Text type="body" size="sm" maxLines={1}>
{row.productname ?? `Product ${row.productid}`}
</Text>
<Text
type="label"
size="sm"
weight="semibold"
style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}
>
{count(row.debit)} sold
</Text>
</HStack>
))}
</VStack>
)}
</Card>
</VStack>
);
}
function TabButton({
label,
icon,
isActive,
onClick,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
return (
<th
style={{
textAlign: align,
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
whiteSpace: 'nowrap',
}}
>
{children}
</td>
);
}

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,608 @@
import { useMemo, useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Info, Monitor, Plus, UserCog, Users } from 'lucide-react';
import type { PosUser, StaffInfo } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
usePosRoles,
usePosUsersByBranch,
useStaff,
useStaffShifts,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchLabel } from '../format';
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
type Group = 'store' | 'till';
/**
* Users & access — the two account systems, side by side but never merged.
*
* A till account is NOT a Nearle Daily user. The backend excludes roles 7 and 8
* from every application lookup *inside the query itself* — deliberately, so a
* cashier is "not found" rather than "refused". Merging the two into one table
* would list people in a directory they cannot sign in to, and would invite an
* edit that moves someone between two systems that do not share a login.
*
* Neither group offers a delete. Both delete endpoints are hard deletes with no
* cascade, and `status` already carries deactivation.
*/
export function UsersPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope();
const [group, setGroup] = useState<Group>('store');
const [keyword, setKeyword] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [editingPerson, setEditingPerson] = useState<StaffInfo | 'new' | null>(null);
const [editingTill, setEditingTill] = useState<{ row: PosUser | 'new'; locationid: number } | null>(
null,
);
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const staff = useStaff(tenantid || undefined);
const posUserPages = usePosUsersByBranch(tenantid || undefined, branchIds);
const roles = usePosRoles();
const shifts = useStaffShifts(tenantid || undefined, scoped[0]?.locationid);
const staffRows = useMemo(() => {
const term = keyword.trim().toLowerCase();
return (staff.data ?? []).filter((person) => {
if (roleFilter && person.rolename !== roleFilter) return false;
if (selected !== null && person.locationid !== selected) return false;
if (!term) return true;
return `${person.fullname} ${person.email} ${person.contactno}`.toLowerCase().includes(term);
});
}, [staff.data, keyword, roleFilter, selected]);
const tillRows = useMemo(() => {
const out: { person: PosUser; branch: string; locationid: number }[] = [];
posUserPages.forEach((page, index) => {
const locationid = branchIds[index];
if (locationid === undefined) return;
const branch = branches.find((entry) => entry.locationid === locationid);
for (const person of page.data ?? []) {
out.push({
person,
branch: branch?.locationname ?? `Branch ${locationid}`,
locationid,
});
}
});
const term = keyword.trim().toLowerCase();
return out.filter(({ person }) => {
if (roleFilter && person.role?.toLowerCase() !== roleFilter.toLowerCase()) return false;
if (!term) return true;
return `${person.full_name} ${person.contactno}`.toLowerCase().includes(term);
});
}, [posUserPages, branchIds, branches, keyword, roleFilter]);
const staffRoles = useMemo(() => {
const seen = new Set<string>();
for (const person of staff.data ?? []) if (person.rolename) seen.add(person.rolename);
return [...seen].sort();
}, [staff.data]);
const roleOptions = useMemo(
() =>
group === 'store'
? [
{ value: '', label: 'All roles' },
...staffRoles.map((name) => ({ value: name, label: name })),
]
: [
{ value: '', label: 'All roles' },
...(roles.data ?? []).map((role) => ({ value: role.role, label: role.label })),
],
[group, staffRoles, roles.data],
);
return (
<VStack gap={3}>
<PageHeader
title="Users & access"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="Who can sign in to the console, and who can open a till."
actions={
<Button
label={group === 'store' ? 'Add person' : 'Add till account'}
variant="primary"
size="sm"
icon={<Plus size={14} />}
onClick={() => {
if (group === 'store') setEditingPerson('new');
else {
const locationid = selected ?? scoped[0]?.locationid;
if (locationid) setEditingTill({ row: 'new', locationid });
}
}}
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<TabButton
label="Store users"
icon={<UserCog size={14} />}
badge={staff.data?.length || undefined}
isActive={group === 'store'}
onClick={() => {
setGroup('store');
setRoleFilter('');
}}
/>
<TabButton
label="Till accounts"
icon={<Monitor size={14} />}
badge={tillRows.length || undefined}
isActive={group === 'till'}
onClick={() => {
setGroup('till');
setRoleFilter('');
}}
/>
</HStack>
}
/>
<VStack gap={1.5}>
<SectionHeader
title={group === 'store' ? 'Back office' : 'Counter staff'}
note={
group === 'store'
? 'signs in to this console'
: 'signs in at a till — no console login'
}
action={
<HStack gap={1} wrap="wrap">
<div style={{ width: 170 }}>
<Selector
label="Role"
isLabelHidden
size="sm"
value={roleFilter}
onChange={setRoleFilter}
placeholder="All roles"
options={roleOptions}
/>
</div>
<div style={{ width: 220 }}>
<TextInput
label="Search people"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Name, email, mobile…"
hasClear
/>
</div>
</HStack>
}
/>
{group === 'store' ? (
<StaffTable
rows={staffRows}
isLoading={staff.isLoading}
showBranch={selected === null}
branches={branches}
onEdit={setEditingPerson}
/>
) : (
<TillTable
rows={tillRows}
isLoading={posUserPages.some((page) => page.isLoading)}
showBranch={selected === null}
onEdit={(row, locationid) => setEditingTill({ row, locationid })}
/>
)}
</VStack>
{/* Stated once, under whichever list is showing. */}
<HStack gap={1} align="start">
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<Text type="body" size="xsm" color="secondary">
{group === 'store' ? (
<>
There is no delete here. Removing an account is a hard delete with no cascade in
Fiesta, so anything that account touched would be left pointing at nothing —
deactivating keeps the history and stops the login. Passwords are not managed from
this console either, because they are stored in clear; adding a person creates the
account, and issuing its password is a backend job until that changes.
</>
) : (
<>
A till account has no console login — the backend leaves roles 7 and 8 out of every
application lookup, so a cashier is simply not found rather than refused. The PIN is
shown once when the account is created and never read back; if it is lost, reissue
rather than look it up.
</>
)}
</Text>
</HStack>
{editingPerson ? (
<PersonDrawer
row={editingPerson === 'new' ? null : editingPerson}
tenantid={tenantid}
branches={branches}
onClose={() => setEditingPerson(null)}
/>
) : null}
{editingTill ? (
<TillDrawer
row={editingTill.row === 'new' ? null : editingTill.row}
tenantid={tenantid}
locationid={editingTill.locationid}
roles={roles.data ?? []}
shifts={shifts.data ?? []}
onClose={() => setEditingTill(null)}
/>
) : null}
</VStack>
);
}
/* ── Tables ──────────────────────────────────────────────────────────────── */
function StaffTable({
rows,
isLoading,
showBranch,
branches,
onEdit,
}: {
rows: StaffInfo[];
isLoading: boolean;
showBranch: boolean;
branches: { locationid: number; locationname: string }[];
onEdit: (row: StaffInfo) => void;
}) {
if (isLoading) return <Loading what="the directory" />;
if (rows.length === 0) {
return (
<Empty
icon={<Users size={22} />}
title="Nobody here"
body="Nothing matches this role or search. Add a person to give them a console login."
/>
);
}
return (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Name</Th>
<Th align="left">Role</Th>
<Th align="left">Email</Th>
<Th align="left">Mobile</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Status</Th>
<Th align="right" />
</tr>
</thead>
<tbody>
{rows.map((person) => {
const branch = branches.find((entry) => entry.locationid === person.locationid);
return (
<tr key={person.userid}>
<Td align="left" isStrong>
{person.fullname || `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() || '—'}
</Td>
<Td align="left">
{/* Server-resolved. Never mapped from roleid here — most
accounts carry an id that is absent from `app_roles`. */}
<Chip label={person.rolename} tone="brand" />
</Td>
<Td align="left" isMuted>
{person.email || '—'}
</Td>
<Td align="left" isMuted>
{person.contactno || '—'}
</Td>
{showBranch ? (
<Td align="left" isMuted>
{branchLabel(branch?.locationname ?? person.locationname)}
</Td>
) : null}
<Td align="left">
<StatusChip status={person.status} />
</Td>
<Td align="right">
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
</Td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}
function TillTable({
rows,
isLoading,
showBranch,
onEdit,
}: {
rows: { person: PosUser; branch: string; locationid: number }[];
isLoading: boolean;
showBranch: boolean;
onEdit: (row: PosUser, locationid: number) => void;
}) {
if (isLoading) return <Loading what="till accounts" />;
if (rows.length === 0) {
return (
<Empty
icon={<Monitor size={22} />}
title="No till accounts"
body="Create a Supervisor first — they open the till on the device, and the terminal appears on the Console board once it reports in."
/>
);
}
return (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Name</Th>
<Th align="left">Role</Th>
<Th align="left">Mobile</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Shift</Th>
<Th align="left">Status</Th>
<Th align="right" />
</tr>
</thead>
<tbody>
{rows.map(({ person, branch, locationid }) => (
<tr key={person.user_id}>
<Td align="left" isStrong>
{person.full_name || '—'}
</Td>
<Td align="left">
<Chip
label={person.role}
tone={person.role?.toLowerCase() === 'supervisor' ? 'violet' : 'slate'}
/>
</Td>
<Td align="left" isMuted>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{person.contactno || '—'}
</span>
</Td>
{showBranch ? (
<Td align="left" isMuted>
{branchLabel(branch)}
</Td>
) : null}
<Td align="left" isMuted>
{person.shift_name ? (
<>
<span style={{ display: 'block' }}>{person.shift_name}</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
{person.shift_start}–{person.shift_end}
</span>
</>
) : (
'Any'
)}
</Td>
<Td align="left">
<StatusChip status={person.status} />
</Td>
<Td align="right">
<Button
label="Edit"
variant="secondary"
size="sm"
onClick={() => onEdit(person, locationid)}
/>
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
/* ── Primitives ──────────────────────────────────────────────────────────── */
const TONE: Record<string, string> = {
brand: 'var(--color-brand)',
violet: '#8b5cf6',
slate: 'var(--color-ink-3)',
};
function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof TONE }) {
const colour = TONE[tone] ?? TONE.slate!;
return (
<span
style={{
display: 'inline-flex',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
textTransform: 'capitalize',
whiteSpace: 'nowrap',
color: colour,
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
}}
>
{label || '—'}
</span>
);
}
/** Active is quiet, anything else is loud — an inactive account is the exception. */
function StatusChip({ status }: { status: string | undefined }) {
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
fontSize: 12,
fontWeight: 500,
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
}}
>
<span
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
/>
{isActive ? 'Active' : (status ?? 'Inactive')}
</span>
);
}
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: isActive ? '#fff' : 'var(--color-ink-3)',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Loading({ what }: { what: string }) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading {what}…
</Text>
</VStack>
</Card>
);
}
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
{body}
</Text>
</VStack>
</Card>
);
}
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
return (
<th
style={{
textAlign: align,
padding: '10px 12px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
padding: '10px 12px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
verticalAlign: 'top',
}}
>
{children}
</td>
);
}