Initial commit
This commit is contained in:
785
src/features/store-admin/pages/ConsolePage.tsx
Normal file
785
src/features/store-admin/pages/ConsolePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user