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