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

797 lines
27 KiB
TypeScript

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,
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 { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
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, isPinned } = useBranchScope();
/**
* Where this page's links point.
*
* The page is shared with the Store user workspace, pinned to one branch, and
* a hardcoded `/admin/...` there sends a shop to a workspace its role cannot
* enter — which the route guard answers with a redirect, so the alert silently
* does nothing.
*/
const base = isPinned ? '/store' : '/admin';
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, base, isPinned), [rows, base, isPinned]);
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" />
<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 ?? 0)}
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>
</VStack>
{/* ── Branch overview ─────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader
title={isPinned ? 'Your shop' : selected === null ? 'Branches' : 'Branch'}
/>
{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" />
<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"
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" />
{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>Metric</Th>
{rows.map((row) => (
<Th key={row.branch.locationid}>
{branchLabel(row.branch.locationname)}
</Th>
))}
<Th>All</Th>
</tr>
</thead>
<tbody>
{metrics.map((metric) => (
<tr key={metric.label}>
<Td isMuted>
{metric.label}
</Td>
{rows.map((row) => (
<Td key={row.branch.locationid}>
{metric.get(row)}
</Td>
))}
<Td 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 })),
);
const paged = usePaged(terminals);
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>Till</Th>
<Th>Branch</Th>
<Th>Status</Th>
<Th>Queued</Th>
<Th>Oldest</Th>
<Th>Today</Th>
<Th>Notes</Th>
</tr>
</thead>
<tbody>
{paged.rows.map(({ branch, terminal }) => (
<tr key={`${branch}:${terminal.terminalId}`}>
<Td isStrong>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{terminal.terminalId}
</span>
</Td>
<Td isMuted>
{branchLabel(branch)}
</Td>
<Td>
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
</Td>
<Td isStrong={terminal.pendingBills > 0}>
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
</Td>
<Td isMuted>
{shortAge(terminal.oldestPendingMs)}
</Td>
<Td>
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
</Td>
<Td isMuted>
{deviceNote(terminal)}
</Td>
</tr>
))}
</tbody>
</table>
<TablePager paged={paged} label="tills" />
</>
);
}
/* ── 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[], base: string, isPinned: boolean): 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: `${base}/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: `${base}/terminals`,
action: 'See tills',
});
}
const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
if (waiting > 0) {
// Same fact, opposite side of the counter. The merchant is the one holding
// it up; the shop is the one waiting — and a shop told to "approve" a
// request it raised itself would go looking for a button that is not there,
// because the backend gives it to nobody but the tenant.
alerts.push(
isPinned
? {
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} waiting on head office`,
detail:
'Your store administrator approves these. Confirm the goods here once they arrive.',
to: `${base}/products`,
action: 'Open Products',
}
: {
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: `${base}/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 }: { children: React.ReactNode; }) {
return (
<th
style={{
textAlign: 'center',
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,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
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: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{children}
</td>
);
}