797 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|